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::auth;
12use adminx_core::csrf;
13use adminx_core::registry::all_resources;
14use adminx_core::request::ReqCtx;
15use adminx_core::resource::Resource;
16use adminx_core::response::{ApiBody, ApiResponse};
17use adminx_core::storage::storage;
18use adminx_core::ui;
19
20use actix_web::http::header::{HeaderName, HeaderValue};
21use actix_web::http::StatusCode;
22use actix_web::{web, HttpRequest, HttpResponse, Scope};
23use serde::Deserialize;
24use serde_json::{json, Value};
25
26/// Prefix the adminx scope is mounted at. Used for building UI links.
27const MOUNT: &str = "/adminx";
28
29#[derive(Deserialize)]
30struct LoginForm {
31    email: String,
32    password: String,
33    /// Hidden CSRF field; absent on a forged post, hence `Option`.
34    #[serde(rename = "_csrf")]
35    csrf: Option<String>,
36}
37
38#[derive(Deserialize)]
39struct CodeForm {
40    code: String,
41    #[serde(rename = "_csrf")]
42    csrf: Option<String>,
43}
44
45/// Convert a neutral response into an Actix response.
46fn into_response(api: ApiResponse) -> HttpResponse {
47    let status = StatusCode::from_u16(api.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
48    let mut builder = HttpResponse::build(status);
49    for (k, v) in &api.headers {
50        if let (Ok(name), Ok(val)) = (
51            HeaderName::from_bytes(k.as_bytes()),
52            HeaderValue::from_str(v),
53        ) {
54            builder.append_header((name, val));
55        }
56    }
57    match api.body {
58        ApiBody::Json(v) => builder.json(v),
59        ApiBody::Bytes { content_type, data } => builder.content_type(content_type).body(data),
60        ApiBody::Empty => builder.finish(),
61    }
62}
63
64/// Build a request context, verifying the auth cookie into `claims` and
65/// carrying the CSRF cookie through for form posts to check.
66fn make_ctx(req: &HttpRequest, mount: &str) -> ReqCtx {
67    let token = req.cookie(auth::COOKIE_NAME).map(|c| c.value().to_string());
68    let csrf_cookie = req.cookie(csrf::COOKIE_NAME).map(|c| c.value().to_string());
69    auth::build_ctx(
70        mount,
71        req.query_string(),
72        token.as_deref(),
73        csrf_cookie.as_deref(),
74    )
75}
76
77/// Build the JSON API + HTML UI routes for a single resource.
78fn resource_routes(resource: Box<dyn Resource>, mount: &'static str) -> Scope {
79    let r: Arc<dyn Resource> = Arc::from(resource);
80    let mut scope = web::scope("");
81
82    // ---------- JSON API ----------
83    scope = scope.route("/api", {
84        let r = r.clone();
85        web::get().to(move |req: HttpRequest| {
86            let r = r.clone();
87            async move { into_response(r.list(&make_ctx(&req, mount)).await) }
88        })
89    });
90    scope = scope.route("/api", {
91        let r = r.clone();
92        web::post().to(move |req: HttpRequest, body: web::Json<Value>| {
93            let r = r.clone();
94            async move { into_response(r.create(&make_ctx(&req, mount), body.into_inner()).await) }
95        })
96    });
97    scope = scope.route("/api/{id}", {
98        let r = r.clone();
99        web::get().to(move |req: HttpRequest, path: web::Path<String>| {
100            let r = r.clone();
101            async move { into_response(r.get(&make_ctx(&req, mount), &path.into_inner()).await) }
102        })
103    });
104    scope = scope.route("/api/{id}", {
105        let r = r.clone();
106        web::put().to(
107            move |req: HttpRequest, path: web::Path<String>, body: web::Json<Value>| {
108                let r = r.clone();
109                async move {
110                    into_response(
111                        r.update(&make_ctx(&req, mount), &path.into_inner(), body.into_inner())
112                            .await,
113                    )
114                }
115            },
116        )
117    });
118    scope = scope.route("/api/{id}", {
119        let r = r.clone();
120        web::delete().to(move |req: HttpRequest, path: web::Path<String>| {
121            let r = r.clone();
122            async move { into_response(r.delete(&make_ctx(&req, mount), &path.into_inner()).await) }
123        })
124    });
125
126    // ---------- HTML UI ----------
127    scope = scope.route("/list", {
128        let r = r.clone();
129        web::get().to(move |req: HttpRequest| {
130            let r = r.clone();
131            async move { into_response(r.list_page(&make_ctx(&req, mount)).await) }
132        })
133    });
134    scope = scope.route("/new", {
135        let r = r.clone();
136        web::get().to(move |req: HttpRequest| {
137            let r = r.clone();
138            async move { into_response(r.new_page(&make_ctx(&req, mount)).await) }
139        })
140    });
141    scope = scope.route("/view/{id}", {
142        let r = r.clone();
143        web::get().to(move |req: HttpRequest, path: web::Path<String>| {
144            let r = r.clone();
145            async move { into_response(r.view_page(&make_ctx(&req, mount), &path.into_inner()).await) }
146        })
147    });
148    scope = scope.route("/edit/{id}", {
149        let r = r.clone();
150        web::get().to(move |req: HttpRequest, path: web::Path<String>| {
151            let r = r.clone();
152            async move { into_response(r.edit_page(&make_ctx(&req, mount), &path.into_inner()).await) }
153        })
154    });
155    scope = scope.route("/create", {
156        let r = r.clone();
157        web::post().to(
158            move |req: HttpRequest, form: web::Form<HashMap<String, String>>| {
159                let r = r.clone();
160                async move {
161                    into_response(r.create_form(&make_ctx(&req, mount), form.into_inner()).await)
162                }
163            },
164        )
165    });
166    scope = scope.route("/update/{id}", {
167        let r = r.clone();
168        web::post().to(
169            move |req: HttpRequest,
170                  path: web::Path<String>,
171                  form: web::Form<HashMap<String, String>>| {
172                let r = r.clone();
173                async move {
174                    into_response(
175                        r.update_form(&make_ctx(&req, mount), &path.into_inner(), form.into_inner())
176                            .await,
177                    )
178                }
179            },
180        )
181    });
182    scope = scope.route("/{id}/delete", {
183        let r = r.clone();
184        web::post().to(
185            move |req: HttpRequest,
186                  path: web::Path<String>,
187                  form: web::Form<HashMap<String, String>>| {
188                let r = r.clone();
189                let mut form = form.into_inner();
190                let csrf = form.remove(csrf::FIELD_NAME);
191                async move {
192                    into_response(r.delete_form(&make_ctx(&req, mount), &path.into_inner(), csrf).await)
193                }
194            },
195        )
196    });
197
198    // ---------- Custom actions ----------
199    scope = scope.route("/{id}/action/{name}", {
200        let r = r.clone();
201        // The action button submits a url-encoded form; parse it as one, use the
202        // remaining fields as the action's JSON body, and lift out `_csrf`.
203        web::post().to(
204            move |req: HttpRequest,
205                  path: web::Path<(String, String)>,
206                  form: web::Form<HashMap<String, String>>| {
207                let r = r.clone();
208                let mut form = form.into_inner();
209                let csrf = form.remove(csrf::FIELD_NAME);
210                let payload = ui::form_to_json(form);
211                async move {
212                    let (id, name) = path.into_inner();
213                    into_response(r.run_action(&make_ctx(&req, mount), &name, id, payload, csrf).await)
214                }
215            },
216        )
217    });
218
219    scope
220}
221
222async fn dashboard(req: HttpRequest, mount: &'static str) -> HttpResponse {
223    let ctx = make_ctx(&req, mount);
224    if let Some(deny) = auth::guard_ui(&ctx) {
225        return into_response(deny);
226    }
227    into_response(ui::dashboard(&ctx))
228}
229
230async fn login_form(req: HttpRequest, mount: &'static str) -> HttpResponse {
231    into_response(auth::login_page(&make_ctx(&req, mount), None))
232}
233
234async fn login_submit(req: HttpRequest, mount: &'static str, form: web::Form<LoginForm>) -> HttpResponse {
235    let ctx = make_ctx(&req, mount);
236    into_response(auth::handle_login(&ctx, &form.email, &form.password, form.csrf.as_deref()).await)
237}
238
239async fn logout(req: HttpRequest, mount: &'static str) -> HttpResponse {
240    into_response(auth::handle_logout(&make_ctx(&req, mount)))
241}
242
243async fn mfa_setup_get(req: HttpRequest, mount: &'static str) -> HttpResponse {
244    into_response(auth::mfa_setup_page(&make_ctx(&req, mount), None).await)
245}
246
247async fn mfa_enable_post(req: HttpRequest, mount: &'static str, form: web::Form<CodeForm>) -> HttpResponse {
248    let ctx = make_ctx(&req, mount);
249    into_response(auth::handle_mfa_enable(&ctx, &form.code, form.csrf.as_deref()).await)
250}
251
252async fn mfa_verify_get(req: HttpRequest, mount: &'static str) -> HttpResponse {
253    into_response(auth::mfa_verify_page(&make_ctx(&req, mount), None).await)
254}
255
256async fn mfa_verify_post(req: HttpRequest, mount: &'static str, form: web::Form<CodeForm>) -> HttpResponse {
257    let ctx = make_ctx(&req, mount);
258    into_response(auth::handle_mfa_verify(&ctx, &form.code, form.csrf.as_deref()).await)
259}
260
261async fn health() -> HttpResponse {
262    let ok = storage().health().await;
263    let body = json!({ "status": if ok { "healthy" } else { "unhealthy" } });
264    into_response(ApiResponse::json(if ok { 200 } else { 503 }, body))
265}
266
267/// Build the full adminx Actix scope, mounted at `/adminx`:
268/// `App::new().service(adminx_actix::scope())`.
269pub fn scope() -> Scope {
270    scope_at(MOUNT)
271}
272
273/// Build the adminx Actix scope at a custom mount prefix. The prefix is both the
274/// scope path and what in-page links, redirects, and form actions are built
275/// from, so one argument keeps them in sync:
276///
277/// ```ignore
278/// App::new().service(adminx_actix::scope_at("/admin"));
279/// ```
280///
281/// The prefix must be a `&'static str` (it is captured by the route handlers).
282pub fn scope_at(mount: &'static str) -> Scope {
283    let mut scope = web::scope(mount)
284        .route("", web::get().to(move |req: HttpRequest| dashboard(req, mount)))
285        .route("/", web::get().to(move |req: HttpRequest| dashboard(req, mount)))
286        .route("/health", web::get().to(health))
287        .route("/login", web::get().to(move |req: HttpRequest| login_form(req, mount)))
288        .route(
289            "/login",
290            web::post().to(move |req: HttpRequest, f: web::Form<LoginForm>| login_submit(req, mount, f)),
291        )
292        .route("/logout", web::get().to(move |req: HttpRequest| logout(req, mount)))
293        .route("/mfa/setup", web::get().to(move |req: HttpRequest| mfa_setup_get(req, mount)))
294        .route(
295            "/mfa/enable",
296            web::post().to(move |req: HttpRequest, f: web::Form<CodeForm>| mfa_enable_post(req, mount, f)),
297        )
298        .route("/mfa/verify", web::get().to(move |req: HttpRequest| mfa_verify_get(req, mount)))
299        .route(
300            "/mfa/verify",
301            web::post().to(move |req: HttpRequest, f: web::Form<CodeForm>| mfa_verify_post(req, mount, f)),
302        );
303
304    let resources = all_resources();
305    tracing::info!("adminx-actix: mounting {} resource(s) at '{}'", resources.len(), mount);
306
307    for resource in resources {
308        let base = format!("/{}", resource.base_path());
309        tracing::info!(
310            "adminx-actix: mounting '{}' at '{}{}'",
311            resource.resource_name(),
312            mount,
313            base
314        );
315        scope = scope.service(web::scope(&base).service(resource_routes(resource, mount)));
316    }
317
318    scope
319}