loco straucture

This commit is contained in:
Priec
2026-06-16 23:40:53 +02:00
parent 9ce07e8c23
commit b88c990873
43 changed files with 378 additions and 102 deletions

View File

@@ -0,0 +1,271 @@
//! Admin category CRUD, including parent/child hierarchy management.
use std::collections::HashSet;
use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter, Set,
};
use serde_json::json;
use crate::{
controllers::{
admin_form::{read_multipart_form, store_image, MultipartForm},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
shared::{
guard,
slug::{slugify, unique_slug},
},
models::{categories, products},
};
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
categories::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
/// Fields parsed from a category form.
struct CategoryFields {
name: String,
slug: String,
description: Option<String>,
position: i32,
published: bool,
parent_id: Option<i32>,
}
async fn parse_category_fields(
ctx: &AppContext,
form: &MultipartForm,
current_id: Option<i32>,
) -> Result<CategoryFields> {
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::<i32>().ok())
.unwrap_or(0);
let published = form.checked("published");
// Resolve the chosen parent, rejecting cycles: a category may not be its
// own parent nor be re-parented under one of its descendants.
let parent_id = match form.text("parent_id").and_then(|s| s.parse::<i32>().ok()) {
Some(parent_id) => {
categories::Entity::find_by_id(parent_id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::BadRequest("parent category not found".to_string()))?;
if let Some(id) = current_id {
if parent_id == id {
return Err(Error::BadRequest(
"a category cannot be its own parent".to_string(),
));
}
if categories::descendant_ids(&categories::all(ctx).await?, id).contains(&parent_id)
{
return Err(Error::BadRequest(
"a category cannot be moved under its own descendant".to_string(),
));
}
}
Some(parent_id)
}
None => None,
};
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(CategoryFields {
name,
slug,
description,
position,
published,
parent_id,
})
}
/// Build the parent-category dropdown options for the category form, as a
/// depth-ordered list of `{ id, name, depth }`. When editing, the category
/// itself and all of its descendants are excluded to keep the tree acyclic.
async fn form_context(
ctx: &AppContext,
jar: &CookieJar,
editing: Option<i32>,
) -> Result<serde_json::Value> {
let all = categories::all(ctx).await?;
let blocked: HashSet<i32> = match editing {
Some(id) => {
let mut set = categories::descendant_ids(&all, id);
set.insert(id);
set
}
None => HashSet::new(),
};
let parents: Vec<serde_json::Value> = categories::tree(&all)
.into_iter()
.filter(|(category, _)| !blocked.contains(&category.id))
.map(|(category, depth)| json!({ "id": category.id, "name": category.name, "depth": depth }))
.collect();
Ok(json!({ "parents": parents, "lang": current_lang(jar) }))
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = categories::all(&ctx).await?;
let mut rows = Vec::new();
for (category, depth) in categories::tree(&list) {
let product_count = products::Entity::find()
.filter(products::Column::CategoryId.eq(category.id))
.count(&ctx.db)
.await?;
rows.push(json!({ "category": category, "depth": depth, "product_count": product_count }));
}
format::view(
&v,
"admin/catalog/categories.html",
json!({ "categories": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let mut context = form_context(&ctx, &jar, None).await?;
context["category"] = serde_json::Value::Null;
format::view(&v, "admin/catalog/category_form.html", context)
}
#[debug_handler]
async fn create(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let fields = 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(fields.name),
slug: Set(fields.slug),
description: Set(fields.description),
image_id: Set(image_id),
position: Set(fields.position),
published: Set(fields.published),
parent_id: Set(fields.parent_id),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/catalog/categories")
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let mut context = form_context(&ctx, &jar, Some(id)).await?;
context["category"] = json!(category_by_id(&ctx, id).await?);
format::view(&v, "admin/catalog/category_form.html", context)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let existing = category_by_id(&ctx, id).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_category_fields(&ctx, &form, Some(id)).await?;
let mut category = existing.into_active_model();
category.name = Set(fields.name);
category.slug = Set(fields.slug);
category.description = Set(fields.description);
category.position = Set(fields.position);
category.published = Set(fields.published);
category.parent_id = Set(fields.parent_id);
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 delete(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/catalog/categories")
}
pub fn routes() -> Routes {
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
Routes::new()
.add("/admin/catalog/categories", get(index))
.add("/admin/catalog/categories/new", get(new))
.add(
"/admin/catalog/categories",
post(create).layer(image_limit.clone()),
)
.add("/admin/catalog/categories/{id}/edit", get(edit))
.add(
"/admin/catalog/categories/{id}",
post(update).layer(image_limit),
)
.add("/admin/catalog/categories/{id}/delete", post(delete))
}

View File

@@ -0,0 +1,54 @@
//! Admin dashboard (HTML home + JSON stats).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{EntityTrait, PaginatorTrait};
use serde::Serialize;
use serde_json::json;
use crate::{controllers::i18n::current_lang, models::_entities, shared::guard};
#[derive(Debug, Serialize)]
struct DashboardResponse {
users: u64,
products: u64,
categories: u64,
orders: u64,
audit_logs: u64,
}
/// JSON dashboard stats, served under `/api/admin`.
#[debug_handler]
async fn dashboard_json(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
format::json(DashboardResponse {
users: _entities::users::Entity::find().count(&ctx.db).await?,
products: _entities::products::Entity::find().count(&ctx.db).await?,
categories: _entities::categories::Entity::find().count(&ctx.db).await?,
orders: _entities::orders::Entity::find().count(&ctx.db).await?,
audit_logs: _entities::audit_logs::Entity::find().count(&ctx.db).await?,
})
}
/// HTML admin home page.
#[debug_handler]
async fn dashboard_page(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let admin_user = guard::current_admin(auth, &ctx).await?;
format::view(
&v,
"admin/index.html",
json!({ "admin": admin_user, "lang": current_lang(&jar) }),
)
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/dashboard", get(dashboard_page))
.add("/api/admin/dashboard", get(dashboard_json))
}

View File

@@ -0,0 +1,87 @@
//! Multipart form handling shared by the product and category admin forms.
//!
//! Both forms submit a mix of text fields and an optional `image` file part;
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver.
use std::collections::HashMap;
use axum::extract::Multipart;
use loco_rs::prelude::*;
use crate::controllers::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
fn normalize_empty(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
(!value.is_empty()).then_some(value)
})
}
/// 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).
pub(crate) struct MultipartForm {
fields: HashMap<String, String>,
pub(crate) image: Option<Vec<u8>>,
}
impl MultipartForm {
/// Trimmed value of a text field, `None` when missing or blank.
pub(crate) fn text(&self, key: &str) -> Option<String> {
normalize_empty(self.fields.get(key).cloned())
}
/// Whether a checkbox-style field is checked.
pub(crate) fn checked(&self, key: &str) -> bool {
matches!(
self.fields.get(key).map(String::as_str),
Some("on" | "true" | "1")
)
}
}
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
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 })
}
/// Store an uploaded image's bytes and return its generated filename.
pub(crate) async fn store_image(ctx: &AppContext, data: Vec<u8>) -> Result<String> {
let extension = detect_image_extension(&data)?;
store_upload(ctx, IMAGE_STORAGE_DIR, extension, data).await
}

View File

@@ -0,0 +1,86 @@
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
//! flow used by the SPA/API).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
controllers::auth as auth_controller,
models::users::{self, LoginParams},
controllers::i18n::current_lang,
shared::guard,
};
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
format::view(
v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if guard::logged_in(&ctx, &jar).await {
return format::redirect("/admin/dashboard");
}
format::view(
&v,
"admin/login.html",
json!({
"error": null,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_error(&v, &jar);
};
if !user.verify_password(&params.password) || !guard::is_admin(&ctx, &user) {
return login_error(&v, &jar);
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin", get(login_page))
.add("/admin/login", get(login_page))
.add("/admin/login", post(login))
.add("/admin/logout", post(logout))
}

View File

@@ -0,0 +1,104 @@
//! Admin order list, detail, and status updates.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
models::{order_items, orders},
views::checkout as view,
controllers::i18n::current_lang,
shared::{guard, settings},
};
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
#[derive(Debug, Deserialize)]
struct StatusForm {
status: String,
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
format::view(
&v,
"admin/orders/index.html",
json!({ "orders": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn show(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let order = orders::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let items = order_items::Entity::find()
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.await?;
format::view(
&v,
"admin/orders/show.html",
json!({
"order": view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
"statuses": ORDER_STATUSES,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn update_status(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<StatusForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
if !ORDER_STATUSES.contains(&form.status.as_str()) {
return Err(Error::BadRequest("invalid status".to_string()));
}
let order = orders::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = order.into_active_model();
active.status = Set(form.status);
active.update(&ctx.db).await?;
format::redirect(&format!("/admin/orders/{id}"))
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/orders", get(index))
.add("/admin/orders/{id}", get(show))
.add("/admin/orders/{id}/status", post(update_status))
}

View File

@@ -0,0 +1,286 @@
//! Admin product CRUD.
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, Set,
};
use serde_json::json;
use crate::{
controllers::{
admin_form::{read_multipart_form, store_image, MultipartForm},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
shared::{
guard,
money::parse_price_to_cents,
slug::{slugify, unique_slug},
},
models::{categories, product_images, products},
views::shop as view,
};
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
products::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
/// Fields parsed from a product form, ready to apply to an active model.
struct ProductFields {
name: String,
slug: String,
description: Option<String>,
price_cents: i64,
currency: String,
sku: Option<String>,
stock: i32,
category_id: Option<i32>,
published: bool,
}
async fn parse_product_fields(
ctx: &AppContext,
form: &MultipartForm,
current_id: Option<i32>,
) -> Result<ProductFields> {
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::<i32>().ok())
.filter(|n| *n >= 0)
.unwrap_or(0);
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().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(ProductFields {
name,
slug,
description,
price_cents,
currency,
sku,
stock,
category_id,
published,
})
}
async fn form_context(ctx: &AppContext, jar: &CookieJar) -> Result<serde_json::Value> {
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 index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::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 = product_images::first_for(&ctx, product.id).await?;
let category_name = match product.category_id {
Some(id) => categories::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.map(|c| c.name),
None => None,
};
rows.push(view::product_card(&product, image, category_name));
}
format::view(
&v,
"admin/catalog/products.html",
json!({ "products": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let mut context = form_context(&ctx, &jar).await?;
context["product"] = serde_json::Value::Null;
format::view(&v, "admin/catalog/product_form.html", context)
}
#[debug_handler]
async fn create(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_product_fields(&ctx, &form, None).await?;
let product = products::ActiveModel {
name: Set(fields.name),
slug: Set(fields.slug),
description: Set(fields.description),
price_cents: Set(fields.price_cents),
currency: Set(fields.currency),
sku: Set(fields.sku),
stock: Set(fields.stock),
view_count: Set(0),
published: Set(fields.published),
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
category_id: Set(fields.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 edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
let image = product_images::first_for(&ctx, id).await?;
let mut context = form_context(&ctx, &jar).await?;
context["product"] = view::product_form(&product, image);
format::view(&v, "admin/catalog/product_form.html", context)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::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 fields = parse_product_fields(&ctx, &form, Some(id)).await?;
let mut product = existing.into_active_model();
product.name = Set(fields.name);
product.slug = Set(fields.slug);
product.description = Set(fields.description);
product.price_cents = Set(fields.price_cents);
product.currency = Set(fields.currency);
product.sku = Set(fields.sku);
product.stock = Set(fields.stock);
product.category_id = Set(fields.category_id);
product.published = Set(fields.published);
if fields.published && !was_published {
product.published_at = Set(Some(chrono::Utc::now().into()));
} else if !fields.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::count_for(&ctx, id).await?;
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 delete(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
product_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/catalog/products")
}
pub fn routes() -> Routes {
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
Routes::new()
.add("/admin/catalog/products", get(index))
.add("/admin/catalog/products/new", get(new))
.add(
"/admin/catalog/products",
post(create).layer(image_limit.clone()),
)
.add("/admin/catalog/products/{id}/edit", get(edit))
.add(
"/admin/catalog/products/{id}",
post(update).layer(image_limit),
)
.add("/admin/catalog/products/{id}/delete", post(delete))
}

View File

@@ -0,0 +1,79 @@
//! Admin management of shipping methods (price + enabled toggle).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
models::shipping_methods,
controllers::i18n::current_lang,
shared::{
guard,
money::{format_price, parse_price_to_cents},
},
};
#[derive(Debug, Deserialize)]
struct ShippingForm {
price: String,
enabled: Option<String>,
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let methods = shipping_methods::Entity::find()
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = methods
.iter()
.map(|m| {
json!({
"id": m.id,
"code": m.code,
"name": m.name,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled,
})
})
.collect();
format::view(
&v,
"admin/shipping/index.html",
json!({ "methods": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<ShippingForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let method = shipping_methods::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = method.into_active_model();
active.price_cents = Set(parse_price_to_cents(&form.price)?);
active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1")));
active.update(&ctx.db).await?;
format::redirect("/admin/shipping")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/shipping", get(index))
.add("/admin/shipping/{id}", post(update))
}

302
src/controllers/auth.rs Normal file
View File

@@ -0,0 +1,302 @@
use crate::{
models::users::{self, LoginParams, RegisterParams},
views::auth::{CurrentResponse, LoginResponse},
mailers::auth::AuthMailer,
shared::guard::is_admin,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use loco_rs::prelude::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use time::Duration as TimeDuration;
pub static EMAIL_DOMAIN_RE: OnceLock<Regex> = OnceLock::new();
pub(crate) const AUTH_COOKIE: &str = "auth_token";
fn get_allow_email_domain_re() -> &'static Regex {
EMAIL_DOMAIN_RE.get_or_init(|| {
Regex::new(r"@example\.com$|@gmail\.com$").expect("Failed to compile regex")
})
}
pub(crate) fn auth_cookie(token: &str, max_age_seconds: u64) -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, token.to_string()))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(max_age_seconds as i64))
.build()
}
pub(crate) fn clear_auth_cookie() -> Cookie<'static> {
Cookie::build((AUTH_COOKIE, ""))
.path("/")
.http_only(true)
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ForgotParams {
pub email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResetParams {
pub token: String,
pub password: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct MagicLinkParams {
pub email: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ResendVerificationParams {
pub email: String,
}
/// Register function creates a new user with the given parameters and sends a
/// welcome email to the user
#[debug_handler]
async fn register(
State(ctx): State<AppContext>,
Json(params): Json<RegisterParams>,
) -> Result<Response> {
let res = users::Model::create_with_password(&ctx.db, &params).await;
let user = match res {
Ok(user) => user,
Err(err) => {
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return format::json(());
}
};
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
AuthMailer::send_welcome(&ctx, &user).await?;
format::json(())
}
/// Verify register user. if the user not verified his email, he can't login to
/// the system.
#[debug_handler]
async fn verify(State(ctx): State<AppContext>, Path(token): Path<String>) -> Result<Response> {
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
return unauthorized("invalid token");
};
if user.email_verified_at.is_some() {
tracing::info!(pid = user.pid.to_string(), "user already verified");
} else {
let active_model = user.into_active_model();
let user = active_model.verified(&ctx.db).await?;
tracing::info!(pid = user.pid.to_string(), "user verified");
}
format::json(())
}
/// In case the user forgot his password this endpoints generate a forgot token
/// and send email to the user. In case the email not found in our DB, we are
/// returning a valid request for for security reasons (not exposing users DB
/// list).
#[debug_handler]
async fn forgot(
State(ctx): State<AppContext>,
Json(params): Json<ForgotParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
return format::json(());
};
let user = user
.into_active_model()
.set_forgot_password_sent(&ctx.db)
.await?;
AuthMailer::forgot_password(&ctx, &user).await?;
format::json(())
}
/// reset user password by the given parameters
#[debug_handler]
async fn reset(State(ctx): State<AppContext>, Json(params): Json<ResetParams>) -> Result<Response> {
let Ok(user) = users::Model::find_by_reset_token(&ctx.db, &params.token).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
tracing::info!("reset token not found");
return format::json(());
};
user.into_active_model()
.reset_password(&ctx.db, &params.password)
.await?;
format::json(())
}
/// Creates a user login and returns a token
#[debug_handler]
async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
tracing::debug!(
email = params.email,
"login attempt with non-existent email"
);
return unauthorized("Invalid credentials!");
};
let valid = user.verify_password(&params.password);
if !valid {
return unauthorized("unauthorized!");
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
}
#[debug_handler]
async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
format::json(CurrentResponse::new(&user, is_admin(&ctx, &user)))
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render().cookies(&[clear_auth_cookie()])?.json(())
}
/// Magic link authentication provides a secure and passwordless way to log in to the application.
///
/// # Flow
/// 1. **Request a Magic Link**:
/// A registered user sends a POST request to `/magic-link` with their email.
/// If the email exists, a short-lived, one-time-use token is generated and sent to the user's email.
/// For security and to avoid exposing whether an email exists, the response always returns 200, even if the email is invalid.
///
/// 2. **Click the Magic Link**:
/// The user clicks the link (/magic-link/{token}), which validates the token and its expiration.
/// If valid, the server generates a JWT and responds with a [`LoginResponse`].
/// If invalid or expired, an unauthorized response is returned.
///
/// This flow enhances security by avoiding traditional passwords and providing a seamless login experience.
async fn magic_link(
State(ctx): State<AppContext>,
Json(params): Json<MagicLinkParams>,
) -> Result<Response> {
let email_regex = get_allow_email_domain_re();
if !email_regex.is_match(&params.email) {
tracing::debug!(
email = params.email,
"The provided email is invalid or does not match the allowed domains"
);
return bad_request("invalid request");
}
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
tracing::debug!(email = params.email, "user not found by email");
return format::empty_json();
};
let user = user.into_active_model().create_magic_link(&ctx.db).await?;
AuthMailer::send_magic_link(&ctx, &user).await?;
format::empty_json()
}
/// Verifies a magic link token and authenticates the user.
async fn magic_link_verify(
Path(token): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_magic_token(&ctx.db, &token).await else {
// we don't want to expose our users email. if the email is invalid we still
// returning success to the caller
return unauthorized("unauthorized!");
};
let user = user.into_active_model().clear_magic_link(&ctx.db).await?;
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
}
#[debug_handler]
async fn resend_verification_email(
State(ctx): State<AppContext>,
Json(params): Json<ResendVerificationParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
tracing::info!(
email = params.email,
"User not found for resend verification"
);
return format::json(());
};
if user.email_verified_at.is_some() {
tracing::info!(
pid = user.pid.to_string(),
"User already verified, skipping resend"
);
return format::json(());
}
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
AuthMailer::send_welcome(&ctx, &user).await?;
tracing::info!(pid = user.pid.to_string(), "Verification email re-sent");
format::json(())
}
pub fn routes() -> Routes {
Routes::new()
.prefix("/api/auth")
.add("/register", post(register))
.add("/verify/{token}", get(verify))
.add("/login", post(login))
.add("/logout", post(logout))
.add("/forgot", post(forgot))
.add("/reset", post(reset))
.add("/current", get(current))
.add("/magic-link", post(magic_link))
.add("/magic-link/{token}", get(magic_link_verify))
.add("/resend-verification-mail", post(resend_verification_email))
}

200
src/controllers/cart.rs Normal file
View File

@@ -0,0 +1,200 @@
use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;
pub(crate) const CART_COOKIE: &str = "cart";
const CART_MAX_AGE_DAYS: i64 = 30;
#[derive(Debug, Deserialize)]
struct AddForm {
product_id: i32,
quantity: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct UpdateForm {
product_id: i32,
quantity: i32,
}
#[derive(Debug, Deserialize)]
struct RemoveForm {
product_id: i32,
}
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_id, quantity)`
/// pairs, silently dropping malformed or non-positive entries.
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
let Some(cookie) = jar.get(CART_COOKIE) else {
return Vec::new();
};
cookie
.value()
.split(',')
.filter_map(|entry| {
let (id, qty) = entry.split_once(':')?;
let id = id.trim().parse::<i32>().ok()?;
let qty = qty.trim().parse::<i32>().ok()?;
(qty > 0).then_some((id, qty))
})
.collect()
}
fn serialize_cart(items: &[(i32, i32)]) -> String {
items
.iter()
.map(|(id, qty)| format!("{id}:{qty}"))
.collect::<Vec<_>>()
.join(",")
}
fn cart_cookie(value: String) -> Cookie<'static> {
Cookie::build((CART_COOKIE, value))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::days(CART_MAX_AGE_DAYS))
.build()
}
/// Look up a published product, returning its current stock cap.
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
Ok(products::Entity::find_by_id(id)
.filter(products::Column::Published.eq(true))
.one(&ctx.db)
.await?)
}
#[debug_handler]
async fn add(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<AddForm>,
) -> Result<Response> {
let Some(product) = published_product(&ctx, form.product_id).await? else {
return Err(Error::NotFound);
};
let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == product.id) {
entry.1 = (entry.1 + add_qty).min(product.stock);
} else {
items.push((product.id, add_qty.min(product.stock)));
}
items.retain(|(_, qty)| *qty > 0);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
#[debug_handler]
async fn update(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<UpdateForm>,
) -> Result<Response> {
let stock = published_product(&ctx, form.product_id)
.await?
.map(|p| p.stock)
.unwrap_or(0);
let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_id) {
entry.1 = clamped;
}
items.retain(|(_, qty)| *qty > 0);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
#[debug_handler]
async fn remove(jar: CookieJar, Form(form): Form<RemoveForm>) -> Result<Response> {
let mut items = parse_cart(&jar);
items.retain(|(id, _)| *id != form.product_id);
format::render()
.cookies(&[cart_cookie(serialize_cart(&items))])?
.redirect("/cart")
}
/// Resolve the cart cookie into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Returns the
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut total: i64 = 0;
for (id, qty) in parse_cart(jar) {
let Some(product) = published_product(ctx, id).await? else {
continue;
};
let qty = qty.clamp(0, product.stock);
if qty == 0 {
continue;
}
let line_total = product.price_cents * i64::from(qty);
total += line_total;
valid.push((product.id, qty));
lines.push(json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"price": format_price(product.price_cents),
"currency": product.currency,
"quantity": qty,
"stock": product.stock,
"line_total": format_price(line_total),
}));
}
Ok((lines, valid, total))
}
#[debug_handler]
async fn show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
let response = format::view(
&v,
"shop/cart.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"lang": current_lang(&jar),
}),
)?;
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
}
pub fn routes() -> Routes {
Routes::new()
.add("/cart", get(show))
.add("/cart/add", post(add))
.add("/cart/update", post(update))
.add("/cart/remove", post(remove))
}

200
src/controllers/checkout.rs Normal file
View File

@@ -0,0 +1,200 @@
//! Public checkout flow: the checkout form, placing an order, and the order
//! confirmation page.
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;
use crate::{
controllers::cart::{resolve_cart, CART_COOKIE},
models::{order_items, orders, shipping_methods},
controllers::i18n::current_lang,
shared::{money::format_price, settings},
views::checkout as view,
};
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
#[derive(Debug, Deserialize)]
struct CheckoutForm {
email: String,
customer_name: String,
address: String,
city: String,
zip: String,
country: String,
note: Option<String>,
payment_method: String,
carrier_code: String,
pickup_point_id: Option<String>,
pickup_point_name: Option<String>,
}
fn trimmed(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn cleared_cart_cookie() -> Cookie<'static> {
Cookie::build((CART_COOKIE, ""))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Enabled.eq(true))
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?)
}
#[debug_handler]
async fn checkout_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
if lines.is_empty() {
return format::redirect("/cart");
}
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await?
.iter()
.map(|m| {
json!({
"code": m.code,
"name": m.name,
"price_cents": m.price_cents,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
})
})
.collect();
format::view(
&v,
"shop/checkout.html",
json!({
"items": lines,
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn place_order(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>,
) -> Result<Response> {
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
if valid.is_empty() {
return format::redirect("/cart");
}
let email =
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
return Err(Error::BadRequest("invalid payment method".to_string()));
}
// Resolve the chosen carrier from the enabled methods (price is taken from
// the DB, never the form, so the customer can't pick their own fee).
let method = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(&form.carrier_code))
.filter(shipping_methods::Column::Enabled.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
let id = form
.pickup_point_id
.as_deref()
.and_then(trimmed)
.ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?;
(Some(id), form.pickup_point_name.as_deref().and_then(trimmed))
} else {
(None, None)
};
let order = orders::place(
&ctx,
&valid,
orders::Checkout {
email,
customer_name: trimmed(&form.customer_name),
address: trimmed(&form.address),
city: trimmed(&form.city),
zip: trimmed(&form.zip),
country: trimmed(&form.country),
note: form.note.as_deref().and_then(trimmed),
payment_method: form.payment_method,
method,
pickup_point_id,
pickup_point_name,
},
)
.await?;
format::render()
.cookies(&[cleared_cart_cookie()])?
.redirect(&format!("/orders/{}", order.order_number))
}
#[debug_handler]
async fn order_confirmation(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(order_number): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let order = orders::Entity::find()
.filter(orders::Column::OrderNumber.eq(order_number))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let items = order_items::Entity::find()
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.await?;
format::view(
&v,
"shop/order_confirmed.html",
json!({
"order": view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
"lang": current_lang(&jar),
}),
)
}
pub fn routes() -> Routes {
Routes::new()
.add("/checkout", get(checkout_page))
.add("/checkout", post(place_order))
.add("/orders/{order_number}", get(order_confirmation))
}

30
src/controllers/home.rs Normal file
View File

@@ -0,0 +1,30 @@
//! Public landing page.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
#[debug_handler]
async fn index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let products = shop::featured_products(&ctx, 8).await?;
format::view(
&v,
"home/index.html",
json!({
"products": products,
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
pub fn routes() -> Routes {
Routes::new().add("/", get(index))
}

63
src/controllers/i18n.rs Normal file
View File

@@ -0,0 +1,63 @@
use axum::{
http::{header, HeaderMap},
response::Redirect,
};
use loco_rs::prelude::*;
use serde::Deserialize;
pub const LANG_COOKIE: &str = "lang";
#[derive(Debug, Deserialize)]
pub struct LangForm {
pub lang: String,
}
pub fn current_lang(jar: &axum_extra::extract::cookie::CookieJar) -> String {
match jar
.get(LANG_COOKIE)
.map(|cookie| cookie.value().to_string())
{
Some(ref lang) if lang == "en" => "en".to_string(),
_ => "sk".to_string(),
}
}
#[debug_handler]
async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Response> {
let lang = if form.lang == "en" { "en" } else { "sk" };
let cookie = format!("{LANG_COOKIE}={lang}; Path=/; Max-Age=31536000; SameSite=Lax");
Ok((
[(header::SET_COOKIE, cookie)],
Redirect::to(&back_path(&headers)),
)
.into_response())
}
fn back_path(headers: &HeaderMap) -> String {
let raw = headers
.get(header::REFERER)
.and_then(|value| value.to_str().ok())
.unwrap_or("/");
if raw.starts_with('/') {
return raw.to_string();
}
if let Some(after_scheme) = raw.split_once("://").map(|(_, rest)| rest) {
if let Some(path_start) = after_scheme.find('/') {
let path = &after_scheme[path_start..];
return if path.starts_with('/') {
path.to_string()
} else {
"/".to_string()
};
}
}
"/".to_string()
}
pub fn routes() -> Routes {
Routes::new().add("/lang", post(set_lang))
}

166
src/controllers/media.rs Normal file
View File

@@ -0,0 +1,166 @@
use crate::shared::guard;
use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart},
http::header,
};
use bytes::Bytes;
use loco_rs::{config::Config, prelude::*};
use serde::Serialize;
use std::path::{Path as StdPath, PathBuf};
use uuid::Uuid;
pub(crate) const IMAGE_MAX_BYTES: usize = 10 * 1024 * 1024;
pub const IMAGE_STORAGE_DIR: &str = "images";
#[derive(Debug, Serialize)]
struct UploadResponse {
filename: String,
url: String,
size: usize,
}
pub fn uploads_root(config: &Config) -> Result<PathBuf> {
config
.settings
.as_ref()
.and_then(|settings| settings.get("uploads_root"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(PathBuf::from)
.ok_or_else(|| Error::string("settings.uploads_root must be configured"))
}
fn safe_filename(filename: &str) -> Result<&str> {
if filename.is_empty()
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains("..")
{
return Err(Error::BadRequest("invalid filename".to_string()));
}
Ok(filename)
}
fn image_content_type(extension: &str) -> &'static str {
match extension {
"gif" => "image/gif",
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"webp" => "image/webp",
_ => "application/octet-stream",
}
}
pub(crate) fn detect_image_extension(data: &[u8]) -> Result<&'static str> {
if data.len() < 12 {
return Err(Error::BadRequest("image file is too small".to_string()));
}
if data.starts_with(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) {
return Ok("png");
}
if data.starts_with(&[0xff, 0xd8, 0xff]) {
return Ok("jpg");
}
if data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
return Ok("webp");
}
if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
return Ok("gif");
}
Err(Error::BadRequest("unsupported image format".to_string()))
}
async fn read_multipart_file(mut multipart: Multipart, max_bytes: usize) -> Result<Vec<u8>> {
while let Some(mut field) = multipart
.next_field()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart data: {err}")))?
{
if field.name() != Some("file") {
continue;
}
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() > max_bytes {
return Err(Error::BadRequest(format!(
"file is larger than {} MB",
max_bytes / 1024 / 1024
)));
}
}
if data.is_empty() {
return Err(Error::BadRequest("empty file upload".to_string()));
}
return Ok(data);
}
Err(Error::BadRequest(
"multipart field `file` is required".to_string(),
))
}
pub(crate) async fn store_upload(
ctx: &AppContext,
folder: &str,
extension: &str,
data: Vec<u8>,
) -> Result<String> {
let filename = format!("{}.{}", Uuid::new_v4(), extension);
let key = format!("{folder}/{filename}");
ctx.storage
.upload(StdPath::new(&key), &Bytes::from(data))
.await?;
Ok(filename)
}
#[debug_handler]
async fn image_upload(
auth: auth::JWT,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let data = read_multipart_file(multipart, IMAGE_MAX_BYTES).await?;
let extension = detect_image_extension(&data)?;
let size = data.len();
let filename = store_upload(&ctx, IMAGE_STORAGE_DIR, extension, data).await?;
format::json(UploadResponse {
url: format!("/images/{filename}"),
filename,
size,
})
}
#[debug_handler]
async fn image_serve(
Path(filename): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let filename = safe_filename(&filename)?;
let extension = filename.rsplit('.').next().unwrap_or("");
let key = format!("{IMAGE_STORAGE_DIR}/{filename}");
let body: Vec<u8> = ctx.storage.download(StdPath::new(&key)).await?;
Response::builder()
.header(header::CONTENT_TYPE, image_content_type(extension))
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.body(Body::from(body))
.map_err(Error::from)
}
pub fn routes() -> Routes {
Routes::new()
.add(
"/images/upload",
post(image_upload).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/images/{filename}", get(image_serve))
}

14
src/controllers/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
pub mod auth;
pub mod admin_categories;
pub mod admin_dashboard;
pub mod admin_form;
pub mod admin_login;
pub mod admin_orders;
pub mod admin_products;
pub mod admin_shipping;
pub mod cart;
pub mod checkout;
pub mod home;
pub mod i18n;
pub mod media;
pub mod shop;

174
src/controllers/shop.rs Normal file
View File

@@ -0,0 +1,174 @@
//! Public storefront: product listings, product detail, category pages and the
//! lazily-loaded category sidebar.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
shared::guard,
models::{categories, product_images, products},
views::shop as view,
};
/// Shape a list of products into card rows, loading each one's primary image.
async fn product_rows(ctx: &AppContext, list: Vec<products::Model>) -> Result<Vec<serde_json::Value>> {
let mut rows = Vec::with_capacity(list.len());
for product in list {
let image = product_images::first_for(ctx, product.id).await?;
rows.push(view::product_card(&product, image, None));
}
Ok(rows)
}
/// 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<Vec<serde_json::Value>> {
let list = products::Entity::find()
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.limit(limit)
.all(&ctx.db)
.await?;
product_rows(ctx, list).await
}
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
/// every page gets it without each handler having to supply category data.
#[debug_handler]
async fn category_sidebar(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let published = categories::published(&ctx).await?;
format::view(
&v,
"shop/_sidebar.html",
json!({
"category_tree": view::sidebar_rows(&categories::tree(&published)),
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let list = products::Entity::find()
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.all(&ctx.db)
.await?;
format::view(
&v,
"shop/index.html",
json!({
"products": product_rows(&ctx, list).await?,
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn show(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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) => categories::Entity::find_by_id(id).one(&ctx.db).await?,
None => None,
};
format::view(
&v,
"shop/show.html",
json!({
"product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())),
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
"category": category,
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn category(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let published = categories::published(&ctx).await?;
let category = published
.iter()
.find(|c| c.slug == slug)
.cloned()
.ok_or_else(|| Error::NotFound)?;
let breadcrumbs = categories::ancestors(&published, category.parent_id);
let children = categories::children_of(&published, category.id);
// Products listed here span this category and all of its descendants, so a
// parent category is never empty just because its products live in leaves.
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id)
.into_iter()
.collect();
category_ids.push(category.id);
let list = products::Entity::find()
.filter(products::Column::CategoryId.is_in(category_ids))
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.all(&ctx.db)
.await?;
format::view(
&v,
"shop/category.html",
json!({
"category": category,
"breadcrumbs": breadcrumbs,
"children": children,
"products": product_rows(&ctx, list).await?,
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
"lang": current_lang(&jar),
}),
)
}
pub fn routes() -> Routes {
Routes::new()
.add("/shop", get(index))
.add("/shop/{slug}", get(show))
.add("/category/{slug}", get(category))
.add("/partials/categories", get(category_sidebar))
}