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