Skip to main content

adminx_axum/
lib.rs

1// adminx-axum/src/lib.rs
2//
3// Axum adapter. Translates the neutral `ApiResponse` into an Axum response,
4// extracts the auth cookie into a `ReqCtx`, and wires each registered resource's
5// JSON API and HTML UI (plus login/logout) onto an `axum::Router`. All behavior
6// lives in `adminx_core`; this is translation only.
7
8use std::collections::HashMap;
9use std::sync::Arc;
10
11use adminx_core::attach::UploadedFile;
12use adminx_core::auth;
13use adminx_core::csrf;
14use adminx_core::registry::all_resources;
15use adminx_core::request::ReqCtx;
16use adminx_core::resource::Resource;
17use adminx_core::response::{ApiBody, ApiResponse};
18use adminx_core::storage::storage;
19use adminx_core::ui;
20
21use axum::body::Body;
22use axum::extract::{Form, Multipart, Path, RawQuery};
23use axum::http::{header::COOKIE, HeaderMap, StatusCode};
24use axum::response::Response;
25use axum::routing::{get, post};
26use axum::{Json, Router};
27use serde::Deserialize;
28use serde_json::{json, Value};
29
30/// Prefix the adminx router is expected to be nested at. Used for UI links.
31const MOUNT: &str = "/adminx";
32
33#[derive(Deserialize)]
34struct LoginForm {
35    email: String,
36    password: String,
37    /// Hidden CSRF field; absent on a forged post, hence `Option`.
38    #[serde(rename = "_csrf")]
39    csrf: Option<String>,
40}
41
42#[derive(Deserialize)]
43struct CodeForm {
44    code: String,
45    #[serde(rename = "_csrf")]
46    csrf: Option<String>,
47}
48
49/// Convert a neutral response into an Axum response.
50fn into_response(api: ApiResponse) -> Response {
51    let status = StatusCode::from_u16(api.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
52    let mut builder = Response::builder().status(status);
53    for (k, v) in &api.headers {
54        builder = builder.header(k, v);
55    }
56    match api.body {
57        ApiBody::Json(v) => builder
58            .header("content-type", "application/json")
59            .body(Body::from(serde_json::to_vec(&v).unwrap_or_default()))
60            .unwrap(),
61        ApiBody::Bytes { content_type, data } => builder
62            .header("content-type", content_type)
63            .body(Body::from(data))
64            .unwrap(),
65        ApiBody::Empty => builder.body(Body::empty()).unwrap(),
66    }
67}
68
69/// Pull a named cookie out of the `Cookie` request header.
70fn cookie_from_headers(headers: &HeaderMap, name: &str) -> Option<String> {
71    let cookie = headers.get(COOKIE)?.to_str().ok()?;
72    let prefix = format!("{name}=");
73    cookie
74        .split(';')
75        .map(|p| p.trim())
76        .find_map(|p| p.strip_prefix(&prefix).map(|v| v.to_string()))
77}
78
79fn make_ctx(headers: &HeaderMap, query: &str, mount: &str) -> ReqCtx {
80    auth::build_ctx(
81        mount,
82        query,
83        cookie_from_headers(headers, auth::COOKIE_NAME).as_deref(),
84        cookie_from_headers(headers, csrf::COOKIE_NAME).as_deref(),
85    )
86}
87
88/// Build the JSON API + HTML UI sub-router for a single resource.
89fn resource_router(resource: Box<dyn Resource>, mount: &'static str) -> Router {
90    let r: Arc<dyn Resource> = Arc::from(resource);
91
92    // ---------- JSON API ----------
93    let api = Router::new()
94        .route(
95            "/api",
96            get({
97                let r = r.clone();
98                move |headers: HeaderMap, RawQuery(q): RawQuery| {
99                    let r = r.clone();
100                    async move {
101                        let ctx = make_ctx(&headers, &q.unwrap_or_default(), mount);
102                        into_response(r.list(&ctx).await)
103                    }
104                }
105            })
106            .post({
107                let r = r.clone();
108                move |headers: HeaderMap, Json(body): Json<Value>| {
109                    let r = r.clone();
110                    async move { into_response(r.create(&make_ctx(&headers, "", mount), body).await) }
111                }
112            }),
113        )
114        .route(
115            "/api/{id}",
116            get({
117                let r = r.clone();
118                move |headers: HeaderMap, Path(id): Path<String>| {
119                    let r = r.clone();
120                    async move { into_response(r.get(&make_ctx(&headers, "", mount), &id).await) }
121                }
122            })
123            .put({
124                let r = r.clone();
125                move |headers: HeaderMap, Path(id): Path<String>, Json(body): Json<Value>| {
126                    let r = r.clone();
127                    async move { into_response(r.update(&make_ctx(&headers, "", mount), &id, body).await) }
128                }
129            })
130            .delete({
131                let r = r.clone();
132                move |headers: HeaderMap, Path(id): Path<String>| {
133                    let r = r.clone();
134                    async move { into_response(r.delete(&make_ctx(&headers, "", mount), &id).await) }
135                }
136            }),
137        );
138
139    // ---------- HTML UI ----------
140    let list = get({
141        let r = r.clone();
142        move |headers: HeaderMap, RawQuery(q): RawQuery| {
143            let r = r.clone();
144            async move {
145                let ctx = make_ctx(&headers, &q.unwrap_or_default(), mount);
146                into_response(r.list_page(&ctx).await)
147            }
148        }
149    });
150    let new = get({
151        let r = r.clone();
152        move |headers: HeaderMap| {
153            let r = r.clone();
154            async move { into_response(r.new_page(&make_ctx(&headers, "", mount)).await) }
155        }
156    });
157    let view = get({
158        let r = r.clone();
159        move |headers: HeaderMap, Path(id): Path<String>| {
160            let r = r.clone();
161            async move { into_response(r.view_page(&make_ctx(&headers, "", mount), &id).await) }
162        }
163    });
164    let history = get({
165        let r = r.clone();
166        move |headers: HeaderMap, Path(id): Path<String>| {
167            let r = r.clone();
168            async move { into_response(r.history_page(&make_ctx(&headers, "", mount), &id).await) }
169        }
170    });
171    let edit = get({
172        let r = r.clone();
173        move |headers: HeaderMap, Path(id): Path<String>| {
174            let r = r.clone();
175            async move { into_response(r.edit_page(&make_ctx(&headers, "", mount), &id).await) }
176        }
177    });
178    let create = post({
179        let r = r.clone();
180        move |headers: HeaderMap, Form(form): Form<HashMap<String, String>>| {
181            let r = r.clone();
182            async move { into_response(r.create_form(&make_ctx(&headers, "", mount), form).await) }
183        }
184    });
185    let update = post({
186        let r = r.clone();
187        move |headers: HeaderMap, Path(id): Path<String>, Form(form): Form<HashMap<String, String>>| {
188            let r = r.clone();
189            async move { into_response(r.update_form(&make_ctx(&headers, "", mount), &id, form).await) }
190        }
191    });
192    let delete = post({
193        let r = r.clone();
194        move |headers: HeaderMap, Path(id): Path<String>, Form(mut form): Form<HashMap<String, String>>| {
195            let r = r.clone();
196            let csrf = form.remove(csrf::FIELD_NAME);
197            async move { into_response(r.delete_form(&make_ctx(&headers, "", mount), &id, csrf).await) }
198        }
199    });
200    let action = post({
201        let r = r.clone();
202        // The action button submits a url-encoded form, so parse it as one (the
203        // remaining fields become the action's JSON body) and lift out `_csrf`.
204        move |headers: HeaderMap, Path((id, name)): Path<(String, String)>, Form(mut form): Form<HashMap<String, String>>| {
205            let r = r.clone();
206            let csrf = form.remove(csrf::FIELD_NAME);
207            let payload = ui::form_to_json(form);
208            async move {
209                into_response(r.run_action(&make_ctx(&headers, "", mount), &name, id, payload, csrf).await)
210            }
211        }
212    });
213
214    // File attachments: multipart in, raw bytes out. The `_csrf` field travels
215    // as a text part of the same multipart body alongside the file.
216    let attach = post({
217        let r = r.clone();
218        move |headers: HeaderMap, Path((id, field)): Path<(String, String)>, mut multipart: Multipart| {
219            let r = r.clone();
220            async move {
221                let mut csrf = None;
222                let mut file = None;
223                while let Ok(Some(part)) = multipart.next_field().await {
224                    match part.name() {
225                        Some(csrf::FIELD_NAME) => {
226                            csrf = part.text().await.ok();
227                        }
228                        Some("file") => {
229                            let filename =
230                                part.file_name().unwrap_or("upload").to_string();
231                            let content_type = part
232                                .content_type()
233                                .unwrap_or("application/octet-stream")
234                                .to_string();
235                            if let Ok(bytes) = part.bytes().await {
236                                file = Some(UploadedFile {
237                                    filename,
238                                    content_type,
239                                    bytes: bytes.to_vec(),
240                                });
241                            }
242                        }
243                        _ => {}
244                    }
245                }
246                let ctx = make_ctx(&headers, "", mount);
247                match file {
248                    Some(file) => {
249                        into_response(r.attach_file(&ctx, &id, &field, csrf, file).await)
250                    }
251                    None => into_response(
252                        adminx_core::CoreError::BadRequest("no file in upload".into()).into(),
253                    ),
254                }
255            }
256        }
257    });
258    let serve = get({
259        let r = r.clone();
260        move |headers: HeaderMap, Path((id, field)): Path<(String, String)>| {
261            let r = r.clone();
262            async move {
263                into_response(r.serve_file(&make_ctx(&headers, "", mount), &id, &field).await)
264            }
265        }
266    });
267    let detach = post({
268        let r = r.clone();
269        move |headers: HeaderMap, Path((id, field)): Path<(String, String)>, Form(mut form): Form<HashMap<String, String>>| {
270            let r = r.clone();
271            let csrf = form.remove(csrf::FIELD_NAME);
272            async move {
273                into_response(r.detach_file(&make_ctx(&headers, "", mount), &id, &field, csrf).await)
274            }
275        }
276    });
277
278    let related_view = get({
279        let r = r.clone();
280        move |headers: HeaderMap, Path((id, name)): Path<(String, String)>| {
281            let r = r.clone();
282            async move {
283                into_response(r.related_page(&make_ctx(&headers, "", mount), &id, &name).await)
284            }
285        }
286    });
287    let related_op = post({
288        let r = r.clone();
289        move |headers: HeaderMap,
290              Path((id, name)): Path<(String, String)>,
291              Form(mut form): Form<HashMap<String, String>>| {
292            let r = r.clone();
293            let csrf = form.remove(csrf::FIELD_NAME);
294            let op = form.remove("op");
295            let member_id = form.remove("member_id");
296            async move {
297                into_response(
298                    r.related_action(&make_ctx(&headers, "", mount), &id, &name, op, member_id, csrf)
299                        .await,
300                )
301            }
302        }
303    });
304
305    let ui_routes = Router::new()
306        .route("/list", list)
307        .route("/new", new)
308        .route("/view/{id}", view)
309        .route("/history/{id}", history)
310        .route("/edit/{id}", edit)
311        .route("/create", create)
312        .route("/update/{id}", update)
313        .route("/{id}/delete", delete)
314        .route("/{id}/action/{name}", action)
315        .route("/{id}/attach/{field}", attach)
316        .route("/{id}/blob/{field}", serve)
317        .route("/{id}/detach/{field}", detach)
318        // One path, two methods: the page and every control on it.
319        .route("/{id}/related/{name}", related_view.merge(related_op));
320
321    api.merge(ui_routes)
322}
323
324async fn dashboard(headers: HeaderMap, mount: &'static str) -> Response {
325    let ctx = make_ctx(&headers, "", mount);
326    if let Some(deny) = auth::guard_ui(&ctx) {
327        return into_response(deny);
328    }
329    into_response(ui::dashboard(&ctx).await)
330}
331
332async fn login_form(headers: HeaderMap, mount: &'static str) -> Response {
333    into_response(auth::login_page(&make_ctx(&headers, "", mount), None))
334}
335
336async fn login_submit(headers: HeaderMap, mount: &'static str, Form(form): Form<LoginForm>) -> Response {
337    let ctx = make_ctx(&headers, "", mount);
338    into_response(auth::handle_login(&ctx, &form.email, &form.password, form.csrf.as_deref()).await)
339}
340
341async fn logout(headers: HeaderMap, mount: &'static str) -> Response {
342    into_response(auth::handle_logout(&make_ctx(&headers, "", mount)))
343}
344
345async fn mfa_setup_get(headers: HeaderMap, mount: &'static str) -> Response {
346    into_response(auth::mfa_setup_page(&make_ctx(&headers, "", mount), None).await)
347}
348
349async fn mfa_enable_post(headers: HeaderMap, mount: &'static str, Form(form): Form<CodeForm>) -> Response {
350    let ctx = make_ctx(&headers, "", mount);
351    into_response(auth::handle_mfa_enable(&ctx, &form.code, form.csrf.as_deref()).await)
352}
353
354async fn mfa_verify_get(headers: HeaderMap, mount: &'static str) -> Response {
355    into_response(auth::mfa_verify_page(&make_ctx(&headers, "", mount), None).await)
356}
357
358async fn mfa_verify_post(headers: HeaderMap, mount: &'static str, Form(form): Form<CodeForm>) -> Response {
359    let ctx = make_ctx(&headers, "", mount);
360    into_response(auth::handle_mfa_verify(&ctx, &form.code, form.csrf.as_deref()).await)
361}
362
363async fn health() -> Response {
364    let ok = storage().health().await;
365    let body = json!({ "status": if ok { "healthy" } else { "unhealthy" } });
366    into_response(ApiResponse::json(if ok { 200 } else { 503 }, body))
367}
368
369/// Build the full adminx router for the default `/adminx` mount:
370/// `Router::new().nest("/adminx", adminx_axum::router())`.
371pub fn router() -> Router {
372    router_at(MOUNT)
373}
374
375/// Build the adminx router for a custom mount prefix. Pass the **same** path you
376/// nest it at, so in-page links, redirects, and form actions resolve correctly:
377///
378/// ```ignore
379/// Router::new().nest("/admin", adminx_axum::router_at("/admin"));
380/// ```
381///
382/// The prefix must be a `&'static str` (it is captured by the route handlers).
383pub fn router_at(mount: &'static str) -> Router {
384    let mut router = Router::new()
385        .route("/", get(move |h: HeaderMap| dashboard(h, mount)))
386        .route("/health", get(health))
387        .route(
388            "/login",
389            get(move |h: HeaderMap| login_form(h, mount))
390                .post(move |h: HeaderMap, f: Form<LoginForm>| login_submit(h, mount, f)),
391        )
392        .route("/logout", get(move |h: HeaderMap| logout(h, mount)))
393        .route("/mfa/setup", get(move |h: HeaderMap| mfa_setup_get(h, mount)))
394        .route(
395            "/mfa/enable",
396            post(move |h: HeaderMap, f: Form<CodeForm>| mfa_enable_post(h, mount, f)),
397        )
398        .route(
399            "/mfa/verify",
400            get(move |h: HeaderMap| mfa_verify_get(h, mount))
401                .post(move |h: HeaderMap, f: Form<CodeForm>| mfa_verify_post(h, mount, f)),
402        );
403
404    let resources = all_resources();
405    tracing::info!("adminx-axum: mounting {} resource(s) at '{}'", resources.len(), mount);
406
407    for resource in resources {
408        let base = format!("/{}", resource.base_path());
409        tracing::info!(
410            "adminx-axum: mounting '{}' at '{}{}'",
411            resource.resource_name(),
412            mount,
413            base
414        );
415        router = router.nest(&base, resource_router(resource, mount));
416    }
417
418    router
419}