use std::collections::HashMap;
use std::sync::Arc;
use adminx_core::attach::UploadedFile;
use adminx_core::auth;
use adminx_core::csrf;
use adminx_core::registry::all_resources;
use adminx_core::request::ReqCtx;
use adminx_core::resource::Resource;
use adminx_core::response::{ApiBody, ApiResponse};
use adminx_core::storage::storage;
use adminx_core::ui;
use axum::body::Body;
use axum::extract::{Form, Multipart, Path, RawQuery};
use axum::http::{header::COOKIE, HeaderMap, StatusCode};
use axum::response::Response;
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
const MOUNT: &str = "/adminx";
#[derive(Deserialize)]
struct LoginForm {
email: String,
password: String,
#[serde(rename = "_csrf")]
csrf: Option<String>,
}
#[derive(Deserialize)]
struct CodeForm {
code: String,
#[serde(rename = "_csrf")]
csrf: Option<String>,
}
fn into_response(api: ApiResponse) -> Response {
let status = StatusCode::from_u16(api.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut builder = Response::builder().status(status);
for (k, v) in &api.headers {
builder = builder.header(k, v);
}
match api.body {
ApiBody::Json(v) => builder
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&v).unwrap_or_default()))
.unwrap(),
ApiBody::Bytes { content_type, data } => builder
.header("content-type", content_type)
.body(Body::from(data))
.unwrap(),
ApiBody::Empty => builder.body(Body::empty()).unwrap(),
}
}
fn cookie_from_headers(headers: &HeaderMap, name: &str) -> Option<String> {
let cookie = headers.get(COOKIE)?.to_str().ok()?;
let prefix = format!("{name}=");
cookie
.split(';')
.map(|p| p.trim())
.find_map(|p| p.strip_prefix(&prefix).map(|v| v.to_string()))
}
fn make_ctx(headers: &HeaderMap, query: &str, mount: &str) -> ReqCtx {
auth::build_ctx(
mount,
query,
cookie_from_headers(headers, auth::COOKIE_NAME).as_deref(),
cookie_from_headers(headers, csrf::COOKIE_NAME).as_deref(),
)
}
fn resource_router(resource: Box<dyn Resource>, mount: &'static str) -> Router {
let r: Arc<dyn Resource> = Arc::from(resource);
let api = Router::new()
.route(
"/api",
get({
let r = r.clone();
move |headers: HeaderMap, RawQuery(q): RawQuery| {
let r = r.clone();
async move {
let ctx = make_ctx(&headers, &q.unwrap_or_default(), mount);
into_response(r.list(&ctx).await)
}
}
})
.post({
let r = r.clone();
move |headers: HeaderMap, Json(body): Json<Value>| {
let r = r.clone();
async move { into_response(r.create(&make_ctx(&headers, "", mount), body).await) }
}
}),
)
.route(
"/api/{id}",
get({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>| {
let r = r.clone();
async move { into_response(r.get(&make_ctx(&headers, "", mount), &id).await) }
}
})
.put({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>, Json(body): Json<Value>| {
let r = r.clone();
async move { into_response(r.update(&make_ctx(&headers, "", mount), &id, body).await) }
}
})
.delete({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>| {
let r = r.clone();
async move { into_response(r.delete(&make_ctx(&headers, "", mount), &id).await) }
}
}),
);
let list = get({
let r = r.clone();
move |headers: HeaderMap, RawQuery(q): RawQuery| {
let r = r.clone();
async move {
let ctx = make_ctx(&headers, &q.unwrap_or_default(), mount);
into_response(r.list_page(&ctx).await)
}
}
});
let new = get({
let r = r.clone();
move |headers: HeaderMap| {
let r = r.clone();
async move { into_response(r.new_page(&make_ctx(&headers, "", mount)).await) }
}
});
let view = get({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>| {
let r = r.clone();
async move { into_response(r.view_page(&make_ctx(&headers, "", mount), &id).await) }
}
});
let history = get({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>| {
let r = r.clone();
async move { into_response(r.history_page(&make_ctx(&headers, "", mount), &id).await) }
}
});
let edit = get({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>| {
let r = r.clone();
async move { into_response(r.edit_page(&make_ctx(&headers, "", mount), &id).await) }
}
});
let create = post({
let r = r.clone();
move |headers: HeaderMap, Form(form): Form<HashMap<String, String>>| {
let r = r.clone();
async move { into_response(r.create_form(&make_ctx(&headers, "", mount), form).await) }
}
});
let update = post({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>, Form(form): Form<HashMap<String, String>>| {
let r = r.clone();
async move { into_response(r.update_form(&make_ctx(&headers, "", mount), &id, form).await) }
}
});
let delete = post({
let r = r.clone();
move |headers: HeaderMap, Path(id): Path<String>, Form(mut form): Form<HashMap<String, String>>| {
let r = r.clone();
let csrf = form.remove(csrf::FIELD_NAME);
async move { into_response(r.delete_form(&make_ctx(&headers, "", mount), &id, csrf).await) }
}
});
let action = post({
let r = r.clone();
move |headers: HeaderMap, Path((id, name)): Path<(String, String)>, Form(mut form): Form<HashMap<String, String>>| {
let r = r.clone();
let csrf = form.remove(csrf::FIELD_NAME);
let payload = ui::form_to_json(form);
async move {
into_response(r.run_action(&make_ctx(&headers, "", mount), &name, id, payload, csrf).await)
}
}
});
let attach = post({
let r = r.clone();
move |headers: HeaderMap, Path((id, field)): Path<(String, String)>, mut multipart: Multipart| {
let r = r.clone();
async move {
let mut csrf = None;
let mut file = None;
while let Ok(Some(part)) = multipart.next_field().await {
match part.name() {
Some(csrf::FIELD_NAME) => {
csrf = part.text().await.ok();
}
Some("file") => {
let filename =
part.file_name().unwrap_or("upload").to_string();
let content_type = part
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
if let Ok(bytes) = part.bytes().await {
file = Some(UploadedFile {
filename,
content_type,
bytes: bytes.to_vec(),
});
}
}
_ => {}
}
}
let ctx = make_ctx(&headers, "", mount);
match file {
Some(file) => {
into_response(r.attach_file(&ctx, &id, &field, csrf, file).await)
}
None => into_response(
adminx_core::CoreError::BadRequest("no file in upload".into()).into(),
),
}
}
}
});
let serve = get({
let r = r.clone();
move |headers: HeaderMap, Path((id, field)): Path<(String, String)>| {
let r = r.clone();
async move {
into_response(r.serve_file(&make_ctx(&headers, "", mount), &id, &field).await)
}
}
});
let detach = post({
let r = r.clone();
move |headers: HeaderMap, Path((id, field)): Path<(String, String)>, Form(mut form): Form<HashMap<String, String>>| {
let r = r.clone();
let csrf = form.remove(csrf::FIELD_NAME);
async move {
into_response(r.detach_file(&make_ctx(&headers, "", mount), &id, &field, csrf).await)
}
}
});
let related_view = get({
let r = r.clone();
move |headers: HeaderMap, Path((id, name)): Path<(String, String)>| {
let r = r.clone();
async move {
into_response(r.related_page(&make_ctx(&headers, "", mount), &id, &name).await)
}
}
});
let related_op = post({
let r = r.clone();
move |headers: HeaderMap,
Path((id, name)): Path<(String, String)>,
Form(mut form): Form<HashMap<String, String>>| {
let r = r.clone();
let csrf = form.remove(csrf::FIELD_NAME);
let op = form.remove("op");
let member_id = form.remove("member_id");
async move {
into_response(
r.related_action(&make_ctx(&headers, "", mount), &id, &name, op, member_id, csrf)
.await,
)
}
}
});
let ui_routes = Router::new()
.route("/list", list)
.route("/new", new)
.route("/view/{id}", view)
.route("/history/{id}", history)
.route("/edit/{id}", edit)
.route("/create", create)
.route("/update/{id}", update)
.route("/{id}/delete", delete)
.route("/{id}/action/{name}", action)
.route("/{id}/attach/{field}", attach)
.route("/{id}/blob/{field}", serve)
.route("/{id}/detach/{field}", detach)
.route("/{id}/related/{name}", related_view.merge(related_op));
api.merge(ui_routes)
}
async fn dashboard(headers: HeaderMap, mount: &'static str) -> Response {
let ctx = make_ctx(&headers, "", mount);
if let Some(deny) = auth::guard_ui(&ctx) {
return into_response(deny);
}
into_response(ui::dashboard(&ctx).await)
}
async fn login_form(headers: HeaderMap, mount: &'static str) -> Response {
into_response(auth::login_page(&make_ctx(&headers, "", mount), None))
}
async fn login_submit(headers: HeaderMap, mount: &'static str, Form(form): Form<LoginForm>) -> Response {
let ctx = make_ctx(&headers, "", mount);
into_response(auth::handle_login(&ctx, &form.email, &form.password, form.csrf.as_deref()).await)
}
async fn logout(headers: HeaderMap, mount: &'static str) -> Response {
into_response(auth::handle_logout(&make_ctx(&headers, "", mount)))
}
async fn mfa_setup_get(headers: HeaderMap, mount: &'static str) -> Response {
into_response(auth::mfa_setup_page(&make_ctx(&headers, "", mount), None).await)
}
async fn mfa_enable_post(headers: HeaderMap, mount: &'static str, Form(form): Form<CodeForm>) -> Response {
let ctx = make_ctx(&headers, "", mount);
into_response(auth::handle_mfa_enable(&ctx, &form.code, form.csrf.as_deref()).await)
}
async fn mfa_verify_get(headers: HeaderMap, mount: &'static str) -> Response {
into_response(auth::mfa_verify_page(&make_ctx(&headers, "", mount), None).await)
}
async fn mfa_verify_post(headers: HeaderMap, mount: &'static str, Form(form): Form<CodeForm>) -> Response {
let ctx = make_ctx(&headers, "", mount);
into_response(auth::handle_mfa_verify(&ctx, &form.code, form.csrf.as_deref()).await)
}
async fn health() -> Response {
let ok = storage().health().await;
let body = json!({ "status": if ok { "healthy" } else { "unhealthy" } });
into_response(ApiResponse::json(if ok { 200 } else { 503 }, body))
}
pub fn router() -> Router {
router_at(MOUNT)
}
pub fn router_at(mount: &'static str) -> Router {
let mut router = Router::new()
.route("/", get(move |h: HeaderMap| dashboard(h, mount)))
.route("/health", get(health))
.route(
"/login",
get(move |h: HeaderMap| login_form(h, mount))
.post(move |h: HeaderMap, f: Form<LoginForm>| login_submit(h, mount, f)),
)
.route("/logout", get(move |h: HeaderMap| logout(h, mount)))
.route("/mfa/setup", get(move |h: HeaderMap| mfa_setup_get(h, mount)))
.route(
"/mfa/enable",
post(move |h: HeaderMap, f: Form<CodeForm>| mfa_enable_post(h, mount, f)),
)
.route(
"/mfa/verify",
get(move |h: HeaderMap| mfa_verify_get(h, mount))
.post(move |h: HeaderMap, f: Form<CodeForm>| mfa_verify_post(h, mount, f)),
);
let resources = all_resources();
tracing::info!("adminx-axum: mounting {} resource(s) at '{}'", resources.len(), mount);
for resource in resources {
let base = format!("/{}", resource.base_path());
tracing::info!(
"adminx-axum: mounting '{}' at '{}{}'",
resource.resource_name(),
mount,
base
);
router = router.nest(&base, resource_router(resource, mount));
}
router
}