use std::sync::Arc;
use axum::{
Extension, Router,
extract::Form,
http::StatusCode,
middleware,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use serde::Deserialize;
use crate::app::staging::AdminStaging;
use crate::auth::{
guard::LoginGuard,
session::{is_admin_authenticated, load_user_middleware, login, logout},
};
use crate::context::template::Request;
use crate::middleware::security::rate_limit_middleware;
use crate::urlpatterns;
use crate::utils::{
aliases::AppResult,
trad::{current_lang, t, tf},
};
use crate::{
admin::{
PrototypeAdminState, config::AdminConfig, middleware::admin_required,
trad::insert_admin_messages,
},
flash_now,
};
#[derive(Clone)]
pub struct AdminState {
pub config: Arc<AdminConfig>,
pub login_guard: Option<Arc<LoginGuard>>,
}
#[derive(Deserialize)]
struct AdminLoginData {
username: String,
password: String,
#[serde(default)]
csrf_token: String,
}
pub fn build_admin_router(admin_staging: AdminStaging, _db: crate::utils::aliases::ADb) -> Router {
let prefix = admin_staging
.config
.prefix
.trim_end_matches('/')
.to_string();
let config = admin_staging.config;
let state = admin_staging.state;
let login_guard = config.login_guard.clone();
let rate_limiter = config.rate_limiter.clone();
let admin_state = Arc::new(AdminState {
config: Arc::new(config.clone()),
login_guard,
});
let login_get_route = urlpatterns! {
&format!("{prefix}/login") => get(admin_login_get), name = "admin_login",
};
let login_post_route = Router::new().route(&format!("{prefix}/login"), post(admin_login_post));
let login_post_route = if let Some(limiter) = rate_limiter {
login_post_route.layer(middleware::from_fn_with_state(
limiter,
rate_limit_middleware,
))
} else {
login_post_route
};
let public_router = login_get_route.merge(login_post_route);
let protected_router = urlpatterns! {
&format!("{prefix}/") => get(admin_dashboard), name = "admin_dashboard",
&prefix => get(admin_dashboard_redirect), name = "admin_dashboard_redirect",
&format!("{prefix}/logout") => get(admin_logout), name = "admin_logout",
};
let generated_router = if let Some(router) = admin_staging.route_admin {
router
} else {
Router::new()
};
let mut router = public_router
.merge(
protected_router
.merge(generated_router)
.layer(middleware::from_fn(admin_required)),
)
.layer(middleware::from_fn_with_state(_db, load_user_middleware))
.layer(Extension(admin_state));
if let Some(state) = state {
let order = config.resource_order.clone();
let config = Arc::new(config);
let registry = match Arc::try_unwrap(state) {
Ok(proto) => match Arc::try_unwrap(proto.registry) {
Ok(mut reg) => {
if !order.is_empty() {
reg.reorder(&order);
}
Arc::new(reg)
}
Err(arc) => arc,
},
Err(arc) => arc.registry.clone(),
};
let merged = Arc::new(PrototypeAdminState { registry, config });
router = router.layer(Extension(merged));
}
router
}
pub const ADMIN_TEMPLATE_SESSION_KEY: &str = "admin_template_override";
async fn admin_dashboard_redirect() -> Response {
Redirect::permanent("/admin/").into_response()
}
async fn admin_dashboard(
mut req: Request,
Extension(admin): Extension<Arc<AdminState>>,
Extension(current_user): Extension<crate::auth::session::CurrentUser>,
proto: Option<Extension<Arc<PrototypeAdminState>>>,
) -> AppResult<Response> {
let db = req.engine.db.clone();
let mut resource_counts: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
let resources: Vec<&crate::admin::AdminResource> = if let Some(Extension(ref state)) = proto {
for (key, entry) in &state.registry.resources {
if let Some(count_fn) = &entry.count_fn {
if let Ok(n) = (count_fn)(db.clone(), None).await {
resource_counts.insert(key.clone(), n);
}
}
}
state
.registry
.all()
.filter(|e| {
if current_user.is_superuser {
return true;
}
current_user.can_access_resource(e.meta.key)
})
.map(|e| &e.meta)
.collect()
} else {
Vec::new()
};
let resource_groups: std::collections::HashMap<String, Vec<String>> = {
use crate::admin::permissions::{groupe, groupes_droits};
use sea_orm::EntityTrait;
let groupes: std::collections::HashMap<_, String> = groupe::Entity::find()
.all(&*db)
.await
.unwrap_or_default()
.into_iter()
.map(|g| (g.id, g.nom))
.collect();
let rows = groupes_droits::Entity::find()
.all(&*db)
.await
.unwrap_or_default();
let mut map: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for r in rows {
let nom = groupes
.get(&r.groupe_id)
.cloned()
.unwrap_or_else(|| r.groupe_id.to_string());
let entry = map.entry(r.resource_key).or_default();
if !entry.contains(&nom) {
entry.push(nom);
}
}
map
};
let session_override: Option<String> = req
.session
.get(ADMIN_TEMPLATE_SESSION_KEY)
.await
.unwrap_or(None);
insert_admin_messages(&mut req.context, "dashboard");
insert_admin_messages(&mut req.context, "base");
req = req
.insert("current_user", ¤t_user)
.insert("site_title", &admin.config.site_title)
.insert("site_url", &admin.config.site_url)
.insert("resources", &resources)
.insert("resource_groups", &resource_groups)
.insert("resource_counts", &resource_counts)
.insert("current_page", "dashboard")
.insert("lang", current_lang().code())
.insert("current_resource", &Option::<String>::None)
.insert("admin_has_session_override", session_override.is_some());
let template = session_override
.as_deref()
.unwrap_or_else(|| admin.config.templates.dashboard.resolve());
req.render(template)
}
async fn admin_login_get(
mut req: Request,
Extension(admin): Extension<Arc<AdminState>>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> AppResult<Response> {
let from_logout = params.get("from").is_some_and(|v| v == "logout");
if !from_logout && is_admin_authenticated(&req.session).await {
return Ok(Redirect::to(&format!("{}/", admin.config.prefix)).into_response());
}
insert_admin_messages(&mut req.context, "login");
req = req
.insert("site_title", &admin.config.site_title)
.insert("site_url", &admin.config.site_url)
.insert("lang", current_lang().code());
req.render(admin.config.templates.login.resolve())
}
async fn admin_login_post(
mut req: Request,
Extension(admin): Extension<Arc<AdminState>>,
Form(data): Form<AdminLoginData>,
) -> Response {
use crate::utils::middleware::csrf::unmask_csrf_token;
use subtle::ConstantTimeEq;
if is_admin_authenticated(&req.session).await {
return Redirect::to(&format!("{}/", admin.config.prefix)).into_response();
}
let csrf_valid = unmask_csrf_token(&data.csrf_token)
.map(|unmasked| {
bool::from(
unmasked
.as_bytes()
.ct_eq(req.csrf_token.as_str().as_bytes()),
)
})
.unwrap_or(false);
if !csrf_valid {
insert_admin_messages(&mut req.context, "login");
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", &admin.config.site_url)
.insert("error", t("csrf.invalid_or_missing").to_string());
return req
.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response);
}
if let Some(guard) = &admin.login_guard {
let key = LoginGuard::effective_key(&data.username, "unknown");
if guard.is_locked(&key) {
let secs = guard.remaining_lockout_secs(&key).unwrap_or(0);
insert_admin_messages(&mut req.context, "login");
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", &admin.config.site_url)
.insert("error", tf("admin.login.error_locked", &[secs]));
return req
.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response);
}
}
let Some(auth) = &admin.config.auth else {
return (
StatusCode::NOT_IMPLEMENTED,
t("admin.access.no_auth_handler").to_string(),
)
.into_response();
};
let result = auth
.authenticate(&data.username, &data.password, &req.engine.db)
.await;
if let Some(user) = result {
if let Some(guard) = &admin.login_guard {
let key = LoginGuard::effective_key(&data.username, "unknown");
guard.record_success(&key);
}
let db_store = req
.engine
.session_db_store
.read()
.ok()
.and_then(|g| g.as_ref().cloned());
let exclusive = req.engine.features.exclusive_login;
if login(
&req.session,
&req.engine.db,
user.user_id,
&user.username,
user.is_staff,
user.is_superuser,
db_store.as_deref(),
exclusive,
)
.await
.is_err()
{
insert_admin_messages(&mut req.context, "login");
insert_admin_messages(&mut req.context, "base");
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", &admin.config.site_url)
.insert("error", t("admin.login.error_session").to_string());
return req
.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response);
}
Redirect::to(&format!("{}/", admin.config.prefix)).into_response()
} else {
if let Some(guard) = &admin.login_guard {
let key = LoginGuard::effective_key(&data.username, "unknown");
guard.record_failure(&key);
}
insert_admin_messages(&mut req.context, "login");
insert_admin_messages(&mut req.context, "base");
req = req
.insert("lang", current_lang().code())
.insert("site_title", &admin.config.site_title)
.insert("site_url", &admin.config.site_url)
.insert("error", t("admin.login.error_credentials").to_string());
req.render(admin.config.templates.login.resolve())
.unwrap_or_else(axum::response::IntoResponse::into_response)
}
}
async fn admin_logout(req: Request, Extension(admin): Extension<Arc<AdminState>>) -> Response {
let session = &req.session;
let db_store = req
.engine
.session_db_store
.read()
.ok()
.and_then(|g| g.as_ref().cloned());
let _ = logout(session, db_store.as_deref()).await;
let login_url = format!("{}/login?from=logout", admin.config.prefix);
flash_now!(info => "You have been logged out");
Redirect::to(&login_url).into_response()
}