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 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
29const MOUNT: &str = "/adminx";
31
32#[derive(Deserialize)]
33struct LoginForm {
34 email: String,
35 password: String,
36 #[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
48fn 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
67fn 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
80fn 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 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 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 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 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 scope = scope.route("/{id}/related/{name}", {
290 let r = r.clone();
291 web::get().to(move |req: HttpRequest, path: web::Path<(String, String)>| {
292 let r = r.clone();
293 async move {
294 let (id, name) = path.into_inner();
295 into_response(r.related_page(&make_ctx(&req, mount), &id, &name).await)
296 }
297 })
298 });
299 scope = scope.route("/{id}/related/{name}", {
300 let r = r.clone();
301 web::post().to(
302 move |req: HttpRequest,
303 path: web::Path<(String, String)>,
304 form: web::Form<HashMap<String, String>>| {
305 let r = r.clone();
306 let mut form = form.into_inner();
307 let csrf = form.remove(csrf::FIELD_NAME);
308 let op = form.remove("op");
309 let member_id = form.remove("member_id");
310 async move {
311 let (id, name) = path.into_inner();
312 into_response(
313 r.related_action(&make_ctx(&req, mount), &id, &name, op, member_id, csrf)
314 .await,
315 )
316 }
317 },
318 )
319 });
320
321 scope = scope.route("/{id}/action/{name}", {
323 let r = r.clone();
324 web::post().to(
327 move |req: HttpRequest,
328 path: web::Path<(String, String)>,
329 form: web::Form<HashMap<String, String>>| {
330 let r = r.clone();
331 let mut form = form.into_inner();
332 let csrf = form.remove(csrf::FIELD_NAME);
333 let payload = ui::form_to_json(form);
334 async move {
335 let (id, name) = path.into_inner();
336 into_response(r.run_action(&make_ctx(&req, mount), &name, id, payload, csrf).await)
337 }
338 },
339 )
340 });
341
342 scope
343}
344
345async fn dashboard(req: HttpRequest, mount: &'static str) -> HttpResponse {
346 let ctx = make_ctx(&req, mount);
347 if let Some(deny) = auth::guard_ui(&ctx) {
348 return into_response(deny);
349 }
350 into_response(ui::dashboard(&ctx).await)
351}
352
353async fn login_form(req: HttpRequest, mount: &'static str) -> HttpResponse {
354 into_response(auth::login_page(&make_ctx(&req, mount), None))
355}
356
357async fn login_submit(req: HttpRequest, mount: &'static str, form: web::Form<LoginForm>) -> HttpResponse {
358 let ctx = make_ctx(&req, mount);
359 into_response(auth::handle_login(&ctx, &form.email, &form.password, form.csrf.as_deref()).await)
360}
361
362async fn logout(req: HttpRequest, mount: &'static str) -> HttpResponse {
363 into_response(auth::handle_logout(&make_ctx(&req, mount)))
364}
365
366async fn mfa_setup_get(req: HttpRequest, mount: &'static str) -> HttpResponse {
367 into_response(auth::mfa_setup_page(&make_ctx(&req, mount), None).await)
368}
369
370async fn mfa_enable_post(req: HttpRequest, mount: &'static str, form: web::Form<CodeForm>) -> HttpResponse {
371 let ctx = make_ctx(&req, mount);
372 into_response(auth::handle_mfa_enable(&ctx, &form.code, form.csrf.as_deref()).await)
373}
374
375async fn mfa_verify_get(req: HttpRequest, mount: &'static str) -> HttpResponse {
376 into_response(auth::mfa_verify_page(&make_ctx(&req, mount), None).await)
377}
378
379async fn mfa_verify_post(req: HttpRequest, mount: &'static str, form: web::Form<CodeForm>) -> HttpResponse {
380 let ctx = make_ctx(&req, mount);
381 into_response(auth::handle_mfa_verify(&ctx, &form.code, form.csrf.as_deref()).await)
382}
383
384async fn health() -> HttpResponse {
385 let ok = storage().health().await;
386 let body = json!({ "status": if ok { "healthy" } else { "unhealthy" } });
387 into_response(ApiResponse::json(if ok { 200 } else { 503 }, body))
388}
389
390pub fn scope() -> Scope {
393 scope_at(MOUNT)
394}
395
396pub fn scope_at(mount: &'static str) -> Scope {
406 let mut scope = web::scope(mount)
407 .route("", web::get().to(move |req: HttpRequest| dashboard(req, mount)))
408 .route("/", web::get().to(move |req: HttpRequest| dashboard(req, mount)))
409 .route("/health", web::get().to(health))
410 .route("/login", web::get().to(move |req: HttpRequest| login_form(req, mount)))
411 .route(
412 "/login",
413 web::post().to(move |req: HttpRequest, f: web::Form<LoginForm>| login_submit(req, mount, f)),
414 )
415 .route("/logout", web::get().to(move |req: HttpRequest| logout(req, mount)))
416 .route("/mfa/setup", web::get().to(move |req: HttpRequest| mfa_setup_get(req, mount)))
417 .route(
418 "/mfa/enable",
419 web::post().to(move |req: HttpRequest, f: web::Form<CodeForm>| mfa_enable_post(req, mount, f)),
420 )
421 .route("/mfa/verify", web::get().to(move |req: HttpRequest| mfa_verify_get(req, mount)))
422 .route(
423 "/mfa/verify",
424 web::post().to(move |req: HttpRequest, f: web::Form<CodeForm>| mfa_verify_post(req, mount, f)),
425 );
426
427 let resources = all_resources();
428 tracing::info!("adminx-actix: mounting {} resource(s) at '{}'", resources.len(), mount);
429
430 for resource in resources {
431 let base = format!("/{}", resource.base_path());
432 tracing::info!(
433 "adminx-actix: mounting '{}' at '{}{}'",
434 resource.resource_name(),
435 mount,
436 base
437 );
438 scope = scope.service(web::scope(&base).service(resource_routes(resource, mount)));
439 }
440
441 scope
442}