better project structure
This commit is contained in:
43
src/shared/guard.rs
Normal file
43
src/shared/guard.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Shared admin-authorization helpers used by both admin and public controllers.
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
use crate::account::models::users;
|
||||
use crate::account::AUTH_COOKIE;
|
||||
use crate::shared::settings;
|
||||
|
||||
/// Is `user` the configured admin (settings.admin_email)?
|
||||
pub fn is_admin(ctx: &AppContext, user: &users::Model) -> bool {
|
||||
settings::get(ctx, "admin_email")
|
||||
.is_some_and(|email| user.email.eq_ignore_ascii_case(email))
|
||||
}
|
||||
|
||||
/// Guard for admin handlers: requires a valid JWT whose user matches the
|
||||
/// configured admin email. Returns the admin user, or an unauthorized error.
|
||||
pub async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::Model> {
|
||||
let user = users::Model::find_by_pid(&ctx.db, &auth.claims.pid).await?;
|
||||
if !is_admin(ctx, &user) {
|
||||
return unauthorized("admin only");
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Soft check for public pages: does the request carry a valid admin auth
|
||||
/// cookie? Never errors — used only to decide whether to show admin chrome.
|
||||
pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool {
|
||||
let Some(cookie) = jar.get(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;
|
||||
};
|
||||
is_admin(ctx, &user)
|
||||
}
|
||||
6
src/shared/mod.rs
Normal file
6
src/shared/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
//! Cross-cutting helpers used across feature slices.
|
||||
|
||||
pub mod guard;
|
||||
pub mod money;
|
||||
pub mod settings;
|
||||
pub mod slug;
|
||||
36
src/shared/money.rs
Normal file
36
src/shared/money.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! Money helpers.
|
||||
//!
|
||||
//! Prices are stored throughout the app as integer **minor units** (cents).
|
||||
//! This module is the single place that converts between that storage form and
|
||||
//! the human-facing decimal strings shown in forms and templates.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// 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 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 fn format_price(cents: i64) -> String {
|
||||
format!("{}.{:02}", cents / 100, (cents % 100).abs())
|
||||
}
|
||||
13
src/shared/settings.rs
Normal file
13
src/shared/settings.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! Typed access to the free-form `settings.*` map from the loaded config.
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// Look up a string-valued `settings.<key>` entry, returning `None` if config
|
||||
/// has no settings map, the key is missing, or the value is not a string.
|
||||
pub fn get<'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())
|
||||
}
|
||||
38
src/shared/slug.rs
Normal file
38
src/shared/slug.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
//! URL slug helpers shared by the catalog admin (products and categories).
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
/// Lowercase a string and collapse every run of non-alphanumeric characters
|
||||
/// into a single dash, trimming dashes from the ends.
|
||||
pub 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()
|
||||
}
|
||||
|
||||
/// Find the first slug that does not already exist, appending `-2`, `-3`, … to
|
||||
/// `base` until `exists` reports the candidate as free. An empty `base` falls
|
||||
/// back to `"item"`.
|
||||
pub 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" } else { base };
|
||||
let mut slug = base.to_string();
|
||||
let mut suffix = 2;
|
||||
while exists(slug.clone()).await? {
|
||||
slug = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
Reference in New Issue
Block a user