better project structure
This commit is contained in:
@@ -1,57 +0,0 @@
|
||||
use crate::models::{
|
||||
_entities::{audit_logs, categories, orders, products, users},
|
||||
users as users_model,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DashboardResponse {
|
||||
users: u64,
|
||||
products: u64,
|
||||
categories: u64,
|
||||
orders: u64,
|
||||
audit_logs: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
pub(crate) fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
pub(crate) async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
||||
let user = users_model::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
|
||||
if !is_admin(ctx, &user) {
|
||||
return unauthorized("admin only");
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn dashboard(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
current_admin(auth, &ctx).await?;
|
||||
|
||||
format::json(DashboardResponse {
|
||||
users: users::Entity::find().count(&ctx.db).await?,
|
||||
products: products::Entity::find().count(&ctx.db).await?,
|
||||
categories: categories::Entity::find().count(&ctx.db).await?,
|
||||
orders: orders::Entity::find().count(&ctx.db).await?,
|
||||
audit_logs: audit_logs::Entity::find().count(&ctx.db).await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
.add("/dashboard", get(dashboard))
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
use crate::{
|
||||
mailers::auth::AuthMailer,
|
||||
models::{
|
||||
_entities::users,
|
||||
users::{LoginParams, RegisterParams},
|
||||
},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
};
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_email(ctx: &AppContext) -> Option<&str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get("admin_email"))
|
||||
.and_then(|email| email.as_str())
|
||||
}
|
||||
|
||||
fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
admin_email(ctx).is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
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, ¶ms).await;
|
||||
|
||||
let user = match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
tracing::info!(
|
||||
message = err.to_string(),
|
||||
user_email = ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.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, ¶ms.email).await else {
|
||||
tracing::debug!(
|
||||
email = params.email,
|
||||
"login attempt with non-existent email"
|
||||
);
|
||||
return unauthorized("Invalid credentials!");
|
||||
};
|
||||
|
||||
let valid = user.verify_password(¶ms.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(¶ms.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, ¶ms.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, ¶ms.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))
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
use crate::{
|
||||
controllers::{catalog::format_price, i18n::current_lang},
|
||||
models::_entities::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))
|
||||
}
|
||||
@@ -1,972 +0,0 @@
|
||||
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<String>) -> Option<String> {
|
||||
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.
|
||||
pub(crate) fn parse_price_to_cents(value: &str) -> Result<i64> {
|
||||
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::<i64>().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<F, Fut>(base: &str, mut exists: F) -> Result<String>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<bool>>,
|
||||
{
|
||||
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<String, String>,
|
||||
image: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MultipartForm {
|
||||
fn text(&self, key: &str) -> Option<String> {
|
||||
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<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 })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Category hierarchy helpers (adjacency list via `parent_id`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Flatten the category forest into a depth-first ordered list of
|
||||
/// `(category, depth)`, sorting siblings by position then name. `depth` is 0
|
||||
/// for top-level categories and increases by one per level — templates use it
|
||||
/// to indent.
|
||||
fn category_tree(categories: &[categories::Model]) -> Vec<(categories::Model, usize)> {
|
||||
let mut children: HashMap<Option<i32>, Vec<&categories::Model>> = HashMap::new();
|
||||
for category in categories {
|
||||
children.entry(category.parent_id).or_default().push(category);
|
||||
}
|
||||
for siblings in children.values_mut() {
|
||||
siblings.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||
}
|
||||
|
||||
fn walk(
|
||||
parent: Option<i32>,
|
||||
depth: usize,
|
||||
children: &HashMap<Option<i32>, Vec<&categories::Model>>,
|
||||
out: &mut Vec<(categories::Model, usize)>,
|
||||
) {
|
||||
if let Some(siblings) = children.get(&parent) {
|
||||
for category in siblings {
|
||||
out.push(((*category).clone(), depth));
|
||||
walk(Some(category.id), depth + 1, children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
walk(None, 0, &children, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Depth-ordered list of `{ name, slug, depth }` for the storefront sidebar,
|
||||
/// rendered as an indented flat list.
|
||||
fn category_sidebar_rows(categories: &[categories::Model]) -> Vec<serde_json::Value> {
|
||||
category_tree(categories)
|
||||
.into_iter()
|
||||
.map(|(category, depth)| {
|
||||
json!({ "name": category.name, "slug": category.slug, "depth": depth })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ids of every descendant of `root` (children, grandchildren, …), not
|
||||
/// including `root` itself.
|
||||
fn descendant_ids(categories: &[categories::Model], root: i32) -> std::collections::HashSet<i32> {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
let mut stack = vec![root];
|
||||
while let Some(id) = stack.pop() {
|
||||
for child in categories.iter().filter(|c| c.parent_id == Some(id)) {
|
||||
if set.insert(child.id) {
|
||||
stack.push(child.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Ancestor chain (root first … immediate parent last) for breadcrumbs.
|
||||
fn ancestors(categories: &[categories::Model], start_parent: Option<i32>) -> Vec<categories::Model> {
|
||||
let mut chain = Vec::new();
|
||||
let mut current = start_parent;
|
||||
while let Some(id) = current {
|
||||
match categories.iter().find(|c| c.id == id) {
|
||||
Some(category) => {
|
||||
current = category.parent_id;
|
||||
chain.push(category.clone());
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
chain.reverse();
|
||||
chain
|
||||
}
|
||||
|
||||
/// All categories, used as the source for tree building and validation.
|
||||
async fn all_categories(ctx: &AppContext) -> Result<Vec<categories::Model>> {
|
||||
Ok(categories::Entity::find().all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
async fn first_image(ctx: &AppContext, product_id: i32) -> Result<Option<String>> {
|
||||
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<String>,
|
||||
category_name: Option<String>,
|
||||
) -> 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<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?;
|
||||
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<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
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<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 admin_product_new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
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<i32>,
|
||||
) -> Result<(String, String, Option<String>, i64, String, Option<String>, i32, Option<i32>, 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::<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((
|
||||
name, slug, description, price_cents, currency, sku, stock, category_id, published,
|
||||
))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_product_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
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<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
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<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
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<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
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<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let list = all_categories(&ctx).await?;
|
||||
let mut rows = Vec::new();
|
||||
for (category, depth) in category_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) }),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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 category_form_context(
|
||||
ctx: &AppContext,
|
||||
jar: &CookieJar,
|
||||
editing: Option<i32>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let all = all_categories(ctx).await?;
|
||||
let blocked = match editing {
|
||||
Some(id) => {
|
||||
let mut set = descendant_ids(&all, id);
|
||||
set.insert(id);
|
||||
set
|
||||
}
|
||||
None => std::collections::HashSet::new(),
|
||||
};
|
||||
let parents: Vec<serde_json::Value> = category_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 admin_category_new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut context = category_form_context(&ctx, &jar, None).await?;
|
||||
context["category"] = serde_json::Value::Null;
|
||||
format::view(&v, "admin/catalog/category_form.html", context)
|
||||
}
|
||||
|
||||
async fn parse_category_fields(
|
||||
ctx: &AppContext,
|
||||
form: &MultipartForm,
|
||||
current_id: Option<i32>,
|
||||
) -> Result<(String, String, Option<String>, i32, bool, Option<i32>)> {
|
||||
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 descendant_ids(&all_categories(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((name, slug, description, position, published, parent_id))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_category_create(
|
||||
auth: auth::JWT,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let form = read_multipart_form(multipart).await?;
|
||||
let (name, slug, description, position, published, parent_id) =
|
||||
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),
|
||||
parent_id: Set(parent_id),
|
||||
..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<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let mut context = category_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 admin_category_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
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, parent_id) =
|
||||
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);
|
||||
category.parent_id = Set(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 admin_category_delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
category_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/categories")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public storefront
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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::Entity::find()
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
format::view(
|
||||
&v,
|
||||
"shop/_sidebar.html",
|
||||
json!({
|
||||
"category_tree": category_sidebar_rows(&published),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_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?;
|
||||
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/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn shop_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) => 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::<Vec<_>>(),
|
||||
"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<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let published = categories::Entity::find()
|
||||
.filter(categories::Column::Published.eq(true))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let category = published
|
||||
.iter()
|
||||
.find(|c| c.slug == slug)
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
|
||||
// Breadcrumb trail and the (published) direct children shown as sub-nav.
|
||||
let breadcrumbs = ancestors(&published, category.parent_id);
|
||||
let mut children: Vec<categories::Model> = published
|
||||
.iter()
|
||||
.filter(|c| c.parent_id == Some(category.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
children.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||
|
||||
// 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> = 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?;
|
||||
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,
|
||||
"breadcrumbs": breadcrumbs,
|
||||
"children": children,
|
||||
"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))
|
||||
.add("/partials/categories", get(category_sidebar))
|
||||
// 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),
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
use crate::{
|
||||
controllers::{admin, auth as auth_controller, i18n::current_lang},
|
||||
models::users::{self, LoginParams},
|
||||
};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn home(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let products = crate::controllers::catalog::featured_products(&ctx, 8).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"home/index.html",
|
||||
json!({
|
||||
"products": products,
|
||||
"logged_in_admin": logged_in_admin(&ctx, &jar).await,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_login_page(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
if logged_in_admin(&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 admin_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, ¶ms.email).await else {
|
||||
return format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": "Invalid credentials",
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
if !user.verify_password(¶ms.password) || !admin::is_admin(&ctx, &user) {
|
||||
return format::view(
|
||||
&v,
|
||||
"admin/login.html",
|
||||
json!({
|
||||
"error": "Invalid credentials",
|
||||
"logged_in_admin": false,
|
||||
"lang": current_lang(&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 admin_logout() -> Result<Response> {
|
||||
format::render()
|
||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
||||
.redirect("/admin/login")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_home(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let admin_user = admin::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("/", get(home))
|
||||
.add("/admin/login", get(admin_login_page))
|
||||
.add("/admin/login", post(admin_login))
|
||||
.add("/admin/logout", post(admin_logout))
|
||||
.add("/admin", get(admin_login_page))
|
||||
.add("/admin/dashboard", get(admin_home))
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
use crate::controllers::admin;
|
||||
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> {
|
||||
admin::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))
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod cart;
|
||||
pub mod catalog;
|
||||
pub mod frontend;
|
||||
pub mod i18n;
|
||||
pub mod media;
|
||||
pub mod orders;
|
||||
@@ -1,455 +0,0 @@
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin,
|
||||
cart::{resolve_cart, CART_COOKIE},
|
||||
catalog::format_price,
|
||||
i18n::current_lang,
|
||||
},
|
||||
models::_entities::{order_items, orders, products, shipping_methods},
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
use uuid::Uuid;
|
||||
|
||||
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||
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 setting<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
|
||||
ctx.config
|
||||
.settings
|
||||
.as_ref()
|
||||
.and_then(|settings| settings.get(key))
|
||||
.and_then(|value| value.as_str())
|
||||
}
|
||||
|
||||
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?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StatusForm {
|
||||
status: String,
|
||||
}
|
||||
|
||||
fn trimmed(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn generate_order_number() -> String {
|
||||
let suffix = Uuid::new_v4().simple().to_string()[..8].to_uppercase();
|
||||
format!("ORD-{suffix}")
|
||||
}
|
||||
|
||||
fn cleared_cart_cookie() -> Cookie<'static> {
|
||||
Cookie::build((CART_COOKIE, ""))
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
#[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": setting(&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 txn = ctx.db.begin().await?;
|
||||
|
||||
// Snapshot prices/names and decrement stock atomically. Re-checking stock
|
||||
// inside the transaction guards against it selling out between cart and pay.
|
||||
let mut subtotal: i64 = 0;
|
||||
let mut currency = "EUR".to_string();
|
||||
let mut snapshots = Vec::new();
|
||||
for (product_id, qty) in &valid {
|
||||
let product = products::Entity::find_by_id(*product_id)
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
||||
if product.stock < *qty {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"not enough stock for {}",
|
||||
product.name
|
||||
)));
|
||||
}
|
||||
currency = product.currency.clone();
|
||||
let line_total = product.price_cents * i64::from(*qty);
|
||||
subtotal += line_total;
|
||||
|
||||
let mut active = product.clone().into_active_model();
|
||||
active.stock = Set(product.stock - *qty);
|
||||
active.update(&txn).await?;
|
||||
|
||||
snapshots.push((product.id, product.name, product.price_cents, *qty));
|
||||
}
|
||||
|
||||
let order = orders::ActiveModel {
|
||||
order_number: Set(generate_order_number()),
|
||||
email: Set(email),
|
||||
customer_name: Set(trimmed(&form.customer_name)),
|
||||
status: Set("pending".to_string()),
|
||||
total_cents: Set(subtotal + method.price_cents),
|
||||
currency: Set(currency),
|
||||
address: Set(trimmed(&form.address)),
|
||||
city: Set(trimmed(&form.city)),
|
||||
zip: Set(trimmed(&form.zip)),
|
||||
country: Set(trimmed(&form.country)),
|
||||
note: Set(form.note.as_deref().and_then(trimmed)),
|
||||
payment_method: Set(Some(form.payment_method.clone())),
|
||||
carrier_code: Set(Some(method.code.clone())),
|
||||
carrier_name: Set(Some(method.name.clone())),
|
||||
shipping_cents: Set(method.price_cents),
|
||||
pickup_point_id: Set(pickup_point_id),
|
||||
pickup_point_name: Set(pickup_point_name),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
|
||||
for (product_id, name, unit_price_cents, qty) in snapshots {
|
||||
order_items::ActiveModel {
|
||||
order_id: Set(order.id),
|
||||
product_id: Set(Some(product_id)),
|
||||
product_name: Set(name),
|
||||
unit_price_cents: Set(unit_price_cents),
|
||||
quantity: Set(qty),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
format::render()
|
||||
.cookies(&[cleared_cart_cookie()])?
|
||||
.redirect(&format!("/orders/{}", order.order_number))
|
||||
}
|
||||
|
||||
async fn order_with_items(
|
||||
ctx: &AppContext,
|
||||
order: &orders::Model,
|
||||
) -> Result<(serde_json::Value, Vec<serde_json::Value>)> {
|
||||
let items = order_items::Entity::find()
|
||||
.filter(order_items::Column::OrderId.eq(order.id))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let items_json = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
json!({
|
||||
"product_name": item.product_name,
|
||||
"quantity": item.quantity,
|
||||
"unit_price": format_price(item.unit_price_cents),
|
||||
"line_total": format_price(item.unit_price_cents * i64::from(item.quantity)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let order_json = json!({
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"email": order.email,
|
||||
"customer_name": order.customer_name,
|
||||
"status": order.status,
|
||||
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
||||
"shipping": format_price(order.shipping_cents),
|
||||
"total": format_price(order.total_cents),
|
||||
"currency": order.currency,
|
||||
"address": order.address,
|
||||
"city": order.city,
|
||||
"zip": order.zip,
|
||||
"country": order.country,
|
||||
"note": order.note,
|
||||
"payment_method": order.payment_method,
|
||||
"carrier_name": order.carrier_name,
|
||||
"pickup_point_name": order.pickup_point_name,
|
||||
// Numeric, sequential order id doubles as the bank variable symbol.
|
||||
"variable_symbol": order.id,
|
||||
"bank_iban": setting(ctx, "bank_iban").unwrap_or(""),
|
||||
"bank_account_name": setting(ctx, "bank_account_name").unwrap_or(""),
|
||||
"created_at": order.created_at.to_rfc3339(),
|
||||
});
|
||||
Ok((order_json, items_json))
|
||||
}
|
||||
|
||||
#[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 (order_json, items) = order_with_items(&ctx, &order).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"shop/order_confirmed.html",
|
||||
json!({ "order": order_json, "items": items, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_orders(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::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(|order| {
|
||||
json!({
|
||||
"id": order.id,
|
||||
"order_number": order.order_number,
|
||||
"email": order.email,
|
||||
"status": order.status,
|
||||
"total": format_price(order.total_cents),
|
||||
"currency": order.currency,
|
||||
"created_at": order.created_at.to_rfc3339(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/index.html",
|
||||
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_order_show(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::current_admin(auth, &ctx).await?;
|
||||
let order = orders::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let (order_json, items) = order_with_items(&ctx, &order).await?;
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/show.html",
|
||||
json!({
|
||||
"order": order_json,
|
||||
"items": items,
|
||||
"statuses": ORDER_STATUSES,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_shipping(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
admin::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) }),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ShippingForm {
|
||||
price: String,
|
||||
enabled: Option<String>,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_shipping_update(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<ShippingForm>,
|
||||
) -> Result<Response> {
|
||||
admin::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(crate::controllers::catalog::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")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn admin_order_status(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<StatusForm>,
|
||||
) -> Result<Response> {
|
||||
admin::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("/checkout", get(checkout_page))
|
||||
.add("/checkout", post(place_order))
|
||||
.add("/orders/{order_number}", get(order_confirmation))
|
||||
.add("/admin/orders", get(admin_orders))
|
||||
.add("/admin/orders/{id}", get(admin_order_show))
|
||||
.add("/admin/orders/{id}/status", post(admin_order_status))
|
||||
.add("/admin/shipping", get(admin_shipping))
|
||||
.add("/admin/shipping/{id}", post(admin_shipping_update))
|
||||
}
|
||||
Reference in New Issue
Block a user