1use 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
30const MOUNT: &str = "/adminx";
32
33#[derive(Deserialize)]
34struct LoginForm {
35 email: String,
36 password: String,
37 #[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
49fn 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
69fn 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
88fn resource_router(resource: Box<dyn Resource>, mount: &'static str) -> Router {
90 let r: Arc<dyn Resource> = Arc::from(resource);
91
92 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 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 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 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 .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
369pub fn router() -> Router {
372 router_at(MOUNT)
373}
374
375pub 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}