use std::sync::Arc;
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::LazyLock;
use autumn_web::flash::Flash;
use autumn_web::prelude::HxResponseExt;
use autumn_web::security::CsrfToken;
use autumn_web::{AppState, AutumnError, AutumnResult};
use axum::extract::{FromRequestParts, Path, Query, State};
use axum::http::request::Parts;
use axum::http::{StatusCode, header};
use axum::middleware::from_fn;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum::routing;
use diesel_async::AsyncPgConnection;
use diesel_async::pooled_connection::deadpool::Pool;
use futures::future::join_all;
use serde::Deserialize;
use serde_json::Value;
use crate::auth::check_role;
use crate::registry::AdminRegistry;
use crate::templates;
use crate::traits::{
AdminError, AdminField, AdminFieldKind, AdminModel, ListParams, SortDirection, record_id,
};
#[derive(Debug, Clone, Default)]
pub struct AdminCsrf(String);
impl AdminCsrf {
#[must_use]
pub fn token(&self) -> &str {
&self.0
}
}
impl<S: Send + Sync> FromRequestParts<S> for AdminCsrf {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let token = parts
.extensions
.get::<CsrfToken>()
.map(|t| t.token().to_owned())
.unwrap_or_default();
Ok(Self(token))
}
}
const ADMIN_JS: &str = include_str!("admin.js");
const ADMIN_JS_HASH: u64 = fnv1a_64(ADMIN_JS.as_bytes());
const fn fnv1a_64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let mut i = 0;
while i < bytes.len() {
hash ^= bytes[i] as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
i += 1;
}
hash
}
pub static ADMIN_JS_PATH: LazyLock<String> =
LazyLock::new(|| format!("/static/admin.{ADMIN_JS_HASH:016x}.js"));
pub fn admin_router(
registry: Arc<AdminRegistry>,
prefix: &str,
actuator_prefix: String,
auth_session_key: String,
require_role: Option<String>,
) -> axum::Router<AppState> {
let router = axum::Router::new()
.route("/", routing::get(dashboard))
.route("/{slug}", routing::get(model_list).post(model_create))
.route("/{slug}/new", routing::get(model_new_form))
.route(
"/{slug}/{id}",
routing::get(model_detail)
.post(model_update)
.delete(model_delete),
)
.route("/{slug}/{id}/edit", routing::get(model_edit_form))
.route("/{slug}/actions", routing::post(model_action))
.route(&ADMIN_JS_PATH, routing::get(serve_admin_js))
.layer(axum::Extension(AdminPrefix(prefix.to_owned())))
.layer(axum::Extension(ActuatorPrefix(actuator_prefix)))
.layer(axum::Extension(registry));
match require_role {
Some(role) => router.layer(from_fn(move |req, next| {
check_role(role.clone(), auth_session_key.clone(), req, next)
})),
None => router,
}
}
#[derive(Clone)]
struct AdminPrefix(String);
#[derive(Clone)]
struct ActuatorPrefix(String);
async fn serve_admin_js() -> Response {
(
[
(header::CONTENT_TYPE, "application/javascript"),
(header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
],
ADMIN_JS,
)
.into_response()
}
#[derive(Debug, Deserialize, Default)]
struct ListQuery {
#[serde(default = "default_page")]
page: u64,
#[serde(default)]
q: String,
#[serde(default)]
sort: Option<String>,
#[serde(default)]
dir: SortDirection,
}
const fn default_page() -> u64 {
1
}
fn resolve<'r>(
state: &AppState,
registry: &'r AdminRegistry,
slug: &str,
) -> AutumnResult<(Pool<AsyncPgConnection>, &'r dyn AdminModel)> {
let pool = state
.pool()
.cloned()
.ok_or_else(|| AutumnError::service_unavailable_msg("No database pool configured"))?;
let model = registry
.get(slug)
.ok_or_else(|| AutumnError::not_found_msg(format!("Model '{slug}' not found")))?;
Ok((pool, model))
}
fn validate_sort_key(sort: Option<String>, fields: &[AdminField]) -> Option<String> {
sort.filter(|s| {
fields.iter().any(|f| {
f.name == s
&& f.sortable
&& f.list_display
&& !matches!(f.kind, AdminFieldKind::Password | AdminFieldKind::Hidden)
})
})
}
fn extract_filters(raw: &HashMap<String, String>, fields: &[AdminField]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = raw
.iter()
.filter_map(|(k, v)| {
let name = k.strip_prefix("filter.")?;
if v.is_empty() {
return None;
}
if !fields.iter().any(|f| f.name == name && f.filterable) {
return None;
}
Some((name.to_owned(), v.clone()))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn admin_err(action: &str, err: AdminError) -> AutumnError {
match err {
AdminError::NotFound => AutumnError::not_found_msg(format!("{action}: not found")),
AdminError::Validation(msg) => AutumnError::bad_request_msg(format!("{action}: {msg}")),
AdminError::Database(msg) => {
AutumnError::internal_server_error_msg(format!("{action}: database error: {msg}"))
}
AdminError::Other(msg) => {
AutumnError::internal_server_error_msg(format!("{action}: {msg}"))
}
}
}
fn render(markup: maud::Markup) -> Response {
Html(markup.into_string()).into_response()
}
async fn dashboard(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let pool = state
.pool()
.cloned()
.ok_or_else(|| AutumnError::service_unavailable_msg("No database pool configured"))?;
let futures: Vec<_> = registry
.iter()
.map(|(slug, model)| {
let pool = pool.clone();
async move {
let count = model.count(&pool).await.unwrap_or(0);
(slug, model.display_name_plural(), count)
}
})
.collect();
let counts = join_all(futures).await;
let messages = flash.consume().await;
Ok(render(templates::dashboard_page(
®istry,
&counts,
&messages,
csrf.token(),
&prefix,
&actuator_prefix,
)))
}
#[allow(clippy::too_many_arguments)]
async fn model_list(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
Path(slug): Path<String>,
Query(query): Query<ListQuery>,
Query(raw_query): Query<HashMap<String, String>>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let ListQuery { page, q, sort, dir } = query;
let page = page.max(1);
let per_page = model.per_page();
let fields = model.fields();
let sort = validate_sort_key(sort, &fields);
let filters = extract_filters(&raw_query, &fields);
let params = ListParams {
page,
per_page,
search: (!q.is_empty()).then(|| q.clone()),
sort_by: sort.clone(),
sort_dir: dir,
filters: filters.clone(),
};
let result = model
.list(&pool, params)
.await
.map_err(|e| admin_err("List", e))?;
let actions = model.actions();
let messages = flash.consume().await;
Ok(render(templates::model_list_page(
®istry,
&slug,
model.display_name_plural(),
&fields,
&actions,
&result,
&q,
sort.as_deref(),
dir,
&filters,
&messages,
csrf.token(),
&prefix,
&actuator_prefix,
)))
}
async fn model_new_form(
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
Path(slug): Path<String>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let model = registry
.get(&slug)
.ok_or_else(|| AutumnError::not_found_msg(format!("Model '{slug}' not found")))?;
let fields = model.fields();
let messages = flash.consume().await;
Ok(render(templates::model_form_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
&fields,
None,
None,
&messages,
csrf.token(),
&prefix,
&actuator_prefix,
)))
}
async fn model_create(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path(slug): Path<String>,
flash: Flash,
axum::extract::Form(form_data): axum::extract::Form<Value>,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let fields = model.fields();
let record = model
.create(&pool, strip_meta_fields(form_data, &fields))
.await
.map_err(|e| admin_err("Create failed", e))?;
let new_id = record_id(&record).ok_or_else(|| {
AutumnError::internal_server_error_msg(format!(
"{} create returned a record without a numeric `id` field; cannot route post-create redirect",
model.display_name()
))
})?;
flash
.success(format!("{} created.", model.display_name()))
.await;
Ok(Redirect::to(&format!("{prefix}/{slug}/{new_id}")).into_response())
}
async fn model_detail(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
Path((slug, id)): Path<(String, i64)>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let record = model
.get(&pool, id)
.await
.map_err(|e| admin_err("Get", e))?
.ok_or_else(|| {
AutumnError::not_found_msg(format!("{} #{id} not found", model.display_name()))
})?;
let display = model.record_display(&record);
let fields = model.fields();
let messages = flash.consume().await;
Ok(render(templates::model_detail_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
&fields,
&record,
&display,
id,
&messages,
csrf.token(),
&prefix,
&actuator_prefix,
)))
}
async fn model_edit_form(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
axum::Extension(ActuatorPrefix(actuator_prefix)): axum::Extension<ActuatorPrefix>,
Path((slug, id)): Path<(String, i64)>,
csrf: AdminCsrf,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let record = model
.get(&pool, id)
.await
.map_err(|e| admin_err("Get", e))?
.ok_or_else(|| {
AutumnError::not_found_msg(format!("{} #{id} not found", model.display_name()))
})?;
let fields = model.fields();
let messages = flash.consume().await;
Ok(render(templates::model_form_page(
®istry,
&slug,
model.display_name(),
model.display_name_plural(),
&fields,
Some(&record),
Some(id),
&messages,
csrf.token(),
&prefix,
&actuator_prefix,
)))
}
async fn model_update(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path((slug, id)): Path<(String, i64)>,
flash: Flash,
axum::extract::Form(form_data): axum::extract::Form<Value>,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let fields = model.fields();
model
.update(&pool, id, strip_meta_fields(form_data, &fields))
.await
.map_err(|e| admin_err("Update failed", e))?;
flash
.success(format!("{} #{id} updated.", model.display_name()))
.await;
Ok(Redirect::to(&format!("{prefix}/{slug}/{id}")).into_response())
}
async fn model_action(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path(slug): Path<String>,
flash: Flash,
body: axum::body::Bytes,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
let mut action: Option<String> = None;
let mut ids: Vec<i64> = Vec::new();
let mut malformed_id = false;
for (k, v) in form_urlencoded::parse(&body) {
match k.as_ref() {
"action" => action = Some(v.into_owned()),
"ids" => match v.parse::<i64>() {
Ok(id) => ids.push(id),
Err(_) => malformed_id = true,
},
_ => {}
}
}
if malformed_id {
return Err(AutumnError::bad_request_msg(
"bulk action: one or more `ids` values were not valid integers",
));
}
let action = action
.ok_or_else(|| AutumnError::bad_request_msg("bulk action: missing `action` form field"))?;
if ids.is_empty() {
return Err(AutumnError::bad_request_msg(
"bulk action: select at least one row",
));
}
if !model.actions().iter().any(|a| a.name == action) {
return Err(AutumnError::bad_request_msg(format!(
"bulk action: '{action}' is not declared by this model"
)));
}
let count = model
.execute_action(&pool, &action, ids)
.await
.map_err(|e| admin_err("Bulk action failed", e))?;
flash
.success(format!("Applied '{action}' to {count} record(s)."))
.await;
Ok(Redirect::to(&format!("{prefix}/{slug}")).into_response())
}
async fn model_delete(
State(state): State<AppState>,
axum::Extension(registry): axum::Extension<Arc<AdminRegistry>>,
axum::Extension(AdminPrefix(prefix)): axum::Extension<AdminPrefix>,
Path((slug, id)): Path<(String, i64)>,
flash: Flash,
) -> AutumnResult<Response> {
let (pool, model) = resolve(&state, ®istry, &slug)?;
model
.delete(&pool, id)
.await
.map_err(|e| admin_err("Delete failed", e))?;
flash
.success(format!("{} #{id} deleted.", model.display_name()))
.await;
Ok(StatusCode::OK.hx_redirect(&format!("{prefix}/{slug}")))
}
fn strip_meta_fields(mut data: Value, fields: &[AdminField]) -> Value {
if let Some(obj) = data.as_object_mut() {
obj.retain(|k, v| {
if k.starts_with('_') {
return false;
}
let Some(field) = fields.iter().find(|f| f.name == k) else {
return false;
};
if matches!(field.kind, AdminFieldKind::Hidden) {
return false;
}
if !field.editable {
return false;
}
!matches!(v, Value::String(s) if s.is_empty() && matches!(field.kind, AdminFieldKind::Password))
});
}
data
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fields(specs: &[(&'static str, AdminFieldKind)]) -> Vec<AdminField> {
specs
.iter()
.cloned()
.map(|(name, kind)| AdminField::new(name, kind))
.collect()
}
#[test]
fn strip_meta_removes_csrf_and_underscore_fields() {
let input = json!({"name": "x", "_csrf": "t", "_foo": 1});
let out = strip_meta_fields(input, &fields(&[("name", AdminFieldKind::Text)]));
assert_eq!(out, json!({"name": "x"}));
}
#[test]
fn strip_meta_drops_blank_password_by_declared_kind() {
let fields = fields(&[
("password", AdminFieldKind::Password),
("other", AdminFieldKind::Text),
]);
let out = strip_meta_fields(json!({"password": "", "other": "y"}), &fields);
assert_eq!(out, json!({"other": "y"}));
let out = strip_meta_fields(json!({"password": "hunter2", "other": "y"}), &fields);
assert_eq!(out, json!({"password": "hunter2", "other": "y"}));
}
#[test]
fn strip_meta_drops_blank_custom_named_password() {
let fields = fields(&[("secret", AdminFieldKind::Password)]);
let out = strip_meta_fields(json!({"secret": ""}), &fields);
assert_eq!(out, json!({}));
}
#[test]
fn strip_meta_preserves_blank_non_password_fields() {
let fields = fields(&[
("name", AdminFieldKind::Text),
("bio", AdminFieldKind::TextArea),
]);
let out = strip_meta_fields(json!({"name": "", "bio": ""}), &fields);
assert_eq!(out, json!({"name": "", "bio": ""}));
}
#[test]
fn validate_sort_key_passes_known_sortable_displayed_fields() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
assert_eq!(
validate_sort_key(Some("name".to_owned()), &fields),
Some("name".to_owned())
);
}
#[test]
fn validate_sort_key_drops_unknown_keys() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
assert_eq!(
validate_sort_key(Some("DROP TABLE users".into()), &fields),
None
);
assert_eq!(validate_sort_key(Some("password".into()), &fields), None);
}
#[test]
fn validate_sort_key_drops_non_sortable_fields() {
let mut computed = AdminField::new("computed", AdminFieldKind::Text);
computed.sortable = false;
let schema = vec![computed];
assert_eq!(validate_sort_key(Some("computed".into()), &schema), None);
}
#[test]
fn validate_sort_key_drops_hidden_columns() {
let mut secret = AdminField::new("secret", AdminFieldKind::Text);
secret.list_display = false;
let schema = vec![secret];
assert_eq!(validate_sort_key(Some("secret".into()), &schema), None);
}
#[test]
fn validate_sort_key_drops_sensitive_kinds_even_if_flagged_sortable() {
let pw = AdminField::new("password_hash", AdminFieldKind::Password);
let hidden = AdminField::new("internal_token", AdminFieldKind::Hidden);
let schema = vec![pw, hidden];
assert_eq!(
validate_sort_key(Some("password_hash".into()), &schema),
None
);
assert_eq!(
validate_sort_key(Some("internal_token".into()), &schema),
None
);
}
#[test]
fn extract_filters_keeps_declared_filterable_fields() {
let mut status = AdminField::new("status", AdminFieldKind::Text);
status.filterable = true;
let schema = vec![status, AdminField::new("name", AdminFieldKind::Text)];
let raw = HashMap::from([
("filter.status".into(), "active".into()),
("filter.name".into(), "alice".into()), ("page".into(), "1".into()), ("filter.unknown".into(), "x".into()), ]);
let out = extract_filters(&raw, &schema);
assert_eq!(out, vec![("status".to_owned(), "active".to_owned())]);
}
#[test]
fn extract_filters_drops_empty_values() {
let mut status = AdminField::new("status", AdminFieldKind::Text);
status.filterable = true;
let schema = vec![status];
let raw = HashMap::from([("filter.status".into(), String::new())]);
assert_eq!(extract_filters(&raw, &schema), vec![]);
}
#[test]
fn extract_filters_handles_no_filters() {
let schema = vec![AdminField::new("name", AdminFieldKind::Text)];
let raw = HashMap::from([("page".into(), "2".into()), ("q".into(), "x".into())]);
assert_eq!(extract_filters(&raw, &schema), vec![]);
}
#[test]
fn extract_filters_sorts_for_stable_output() {
let mut a = AdminField::new("zeta", AdminFieldKind::Text);
a.filterable = true;
let mut b = AdminField::new("alpha", AdminFieldKind::Text);
b.filterable = true;
let schema = vec![a, b];
let raw = HashMap::from([
("filter.zeta".into(), "z".into()),
("filter.alpha".into(), "a".into()),
]);
let out = extract_filters(&raw, &schema);
assert_eq!(
out,
vec![
("alpha".to_owned(), "a".to_owned()),
("zeta".to_owned(), "z".to_owned()),
]
);
}
#[test]
fn validate_sort_key_passes_through_none() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
assert_eq!(validate_sort_key(None, &fields), None);
}
#[test]
fn admin_err_maps_variants_to_correct_status() {
use axum::http::StatusCode;
assert_eq!(
admin_err("X", AdminError::NotFound).status(),
StatusCode::NOT_FOUND
);
assert_eq!(
admin_err("X", AdminError::Validation("bad".into())).status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
admin_err("X", AdminError::Database("pg down".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
admin_err("X", AdminError::Other("boom".into())).status(),
StatusCode::INTERNAL_SERVER_ERROR
);
}
#[tokio::test]
async fn admin_csrf_extractor_returns_empty_when_layer_missing() {
let req = axum::http::Request::builder().uri("/").body(()).unwrap();
let (mut parts, ()) = req.into_parts();
let extracted = AdminCsrf::from_request_parts(&mut parts, &())
.await
.expect("infallible");
assert_eq!(extracted.token(), "");
}
#[tokio::test]
async fn admin_csrf_extractor_reads_token_from_extensions() {
use axum::Router;
use axum::body::Body;
use axum::http::StatusCode;
use axum::routing::get;
use tower::ServiceExt;
async fn handler(csrf: AdminCsrf) -> String {
csrf.token().to_owned()
}
let app = Router::new().route("/", get(handler));
let res = app
.oneshot(
axum::http::Request::builder()
.uri("/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK);
}
#[test]
fn strip_meta_keeps_field_named_password_if_not_declared_as_such() {
let fields = fields(&[("password", AdminFieldKind::Text)]);
let out = strip_meta_fields(json!({"password": ""}), &fields);
assert_eq!(out, json!({"password": ""}));
}
#[test]
fn strip_meta_drops_fields_not_in_schema() {
let fields = fields(&[("name", AdminFieldKind::Text)]);
let input = json!({"name": "x", "is_admin": true, "raw_column": "y"});
let out = strip_meta_fields(input, &fields);
assert_eq!(out, json!({"name": "x"}));
}
#[test]
fn strip_meta_drops_hidden_fields_even_if_editable_true() {
let mut hidden = AdminField::new("owner_id", AdminFieldKind::Hidden);
hidden.editable = true; let schema = vec![hidden];
let out = strip_meta_fields(json!({"owner_id": 999}), &schema);
assert_eq!(out, json!({}));
}
#[test]
fn strip_meta_drops_readonly_fields() {
let mut id = AdminField::new("id", AdminFieldKind::Integer);
id.editable = false;
let mut created_at = AdminField::new("created_at", AdminFieldKind::DateTime);
created_at.editable = false;
let name = AdminField::new("name", AdminFieldKind::Text);
let schema = vec![id, created_at, name];
let input = json!({
"id": 999,
"created_at": "2026-01-01T00:00:00Z",
"name": "legit",
});
let out = strip_meta_fields(input, &schema);
assert_eq!(out, json!({"name": "legit"}));
}
}