Skip to main content

adminx_actix/
lib.rs

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