1use std::sync::Arc;
4
5use axum::{
6 Form, Router,
7 body::{Body, Bytes, to_bytes},
8 extract::{Query, State},
9 http::{HeaderMap, Request, StatusCode, header},
10 response::{Html, IntoResponse},
11 routing::{get, post},
12};
13use krabka_units::prelude::*;
14use serde::{Deserialize, de::DeserializeOwned};
15
16use crate::{
17 auth::{AdminClientLoginBroker, LoginBroker, LoginRequest},
18 config::AdminUiConfig,
19 dto::{
20 AclRequestDto, AlterConfigFormDto, CreatePartitionsRequestDto, CreateTopicFormDto,
21 DeleteTopicRequestDto, LogDirMoveRequestDto, MutationForm, QuotaDeleteDto, QuotaUpsertDto,
22 ResourceOutcome, ScramUserDeleteDto, ScramUserUpsertDto,
23 },
24 error::UiError,
25 permissions::Capabilities,
26 server_fns::{self, AdminSeamFactory, BrokerAdminSeamFactory, ServerFunctionContext},
27 session::{CSRF_FIELD_NAME, CSRF_HEADER_NAME, SessionRecord, SessionStore},
28 views::{
29 OperatorView, ReadRouteState, Route, RoutePage, render_page, render_page_for_operator,
30 },
31};
32
33pub const SESSION_COOKIE_NAME: &str = "krabka_admin_session";
34
35#[derive(Debug, Clone)]
36pub struct AppState {
37 pub cfg: Arc<AdminUiConfig>,
38 pub sessions: Arc<SessionStore>,
39}
40
41#[derive(Clone)]
42pub struct AdminRouterState<F, B = AdminClientLoginBroker> {
43 app: AppState,
44 seam_factory: F,
45 login_broker: B,
46}
47
48impl AppState {
49 #[must_use]
50 pub fn new(cfg: AdminUiConfig) -> Self {
51 let session_ttl = cfg.session_ttl.to_std();
52
53 Self {
54 cfg: Arc::new(cfg),
55 sessions: Arc::new(SessionStore::new(session_ttl)),
56 }
57 }
58
59 #[must_use]
60 pub const fn from_parts(cfg: Arc<AdminUiConfig>, sessions: Arc<SessionStore>) -> Self {
61 Self { cfg, sessions }
62 }
63
64 #[must_use]
65 pub fn sessions_ttl_seconds(&self) -> u64 {
66 self.sessions.ttl().as_secs()
67 }
68}
69
70enum MutationBody {
72 Json(Bytes),
74 Form(String),
77}
78
79#[derive(Debug, Clone, Deserialize)]
81pub struct QuotaLookup {
82 pub entity: Option<String>,
83}
84
85impl QuotaLookup {
86 fn requested_entity(self) -> Option<String> {
87 self.entity
88 .map(|entity| entity.trim().to_string())
89 .filter(|entity| !entity.is_empty())
90 }
91}
92
93pub fn health_router() -> Router {
94 Router::new().route("/healthz", get(healthz))
95}
96
97pub fn router(state: AppState) -> Router {
98 router_with_factory_and_login_broker(state, BrokerAdminSeamFactory, AdminClientLoginBroker)
99}
100
101pub fn router_with_factory<F>(state: AppState, seam_factory: F) -> Router
102where
103 F: AdminSeamFactory + Clone + Send + Sync + 'static,
104 for<'a> F::Reader<'a>: Send,
105 for<'a> F::Mutations<'a>: Send,
106{
107 router_with_factory_and_login_broker(state, seam_factory, AdminClientLoginBroker)
108}
109
110pub fn router_with_factory_and_login_broker<F, B>(
111 state: AppState,
112 seam_factory: F,
113 login_broker: B,
114) -> Router
115where
116 F: AdminSeamFactory + Clone + Send + Sync + 'static,
117 for<'a> F::Reader<'a>: Send,
118 for<'a> F::Mutations<'a>: Send,
119 B: LoginBroker + Clone + Send + Sync + 'static,
120{
121 let router_state = AdminRouterState {
122 app: state,
123 seam_factory,
124 login_broker,
125 };
126
127 Router::new()
128 .route("/healthz", get(healthz))
129 .route("/", get(root::<F, B>))
130 .route("/login", get(login).post(post_login::<F, B>))
131 .route("/logout", post(post_logout::<F, B>))
132 .route("/topics", get(topics))
133 .route("/groups", get(groups))
134 .route("/acls", get(acls))
135 .route("/users", get(users))
136 .route("/quotas", get(quotas))
137 .route("/log-dirs", get(log_dirs))
138 .route("/topics/create", post(post_create_topic::<F, B>))
139 .route("/topics/delete", post(post_delete_topic::<F, B>))
140 .route("/topics/partitions", post(post_create_partitions::<F, B>))
141 .route("/topics/configs", post(post_alter_configs::<F, B>))
142 .route("/acls/create", post(post_create_acl::<F, B>))
143 .route("/acls/delete", post(post_delete_acl::<F, B>))
144 .route("/users/scram/upsert", post(post_upsert_scram::<F, B>))
145 .route("/users/scram/delete", post(post_delete_scram::<F, B>))
146 .route("/quotas/upsert", post(post_upsert_quota::<F, B>))
147 .route("/quotas/delete", post(post_delete_quota::<F, B>))
148 .route("/log-dirs/move", post(post_move_log_dir::<F, B>))
149 .with_state(router_state)
150}
151
152async fn healthz() -> StatusCode {
153 StatusCode::OK
154}
155
156async fn root<F, B>(State(state): State<AdminRouterState<F, B>>, headers: HeaderMap) -> Html<String>
157where
158 F: AdminSeamFactory + Clone + Send + Sync + 'static,
159 for<'a> F::Reader<'a>: Send,
160 B: LoginBroker + Clone + Send + Sync + 'static,
161{
162 let Some((_, operator)) = authenticated_page(&state, &headers) else {
163 return Html(render_page(&RoutePage::for_unauthenticated_route(
164 Route::Overview,
165 )));
166 };
167
168 Html(render_page_for_operator(&RoutePage::overview(), &operator))
169}
170
171async fn login() -> Html<String> {
172 login_page()
173}
174
175async fn post_login<F, B>(
176 State(state): State<AdminRouterState<F, B>>,
177 Form(request): Form<LoginRequest>,
178) -> impl IntoResponse
179where
180 F: AdminSeamFactory + Clone + Send + Sync + 'static,
181 for<'a> F::Reader<'a>: Send,
182 B: LoginBroker + Clone + Send + Sync + 'static,
183{
184 let result = server_fns::login_with_app_state(&state.app, &state.login_broker, request).await;
185
186 let success = match result {
187 Ok(success) => success,
188 Err(error) => {
189 let (status, page) = login_failure_response(&error);
190
191 return (status, Html(render_page(&page))).into_response();
192 }
193 };
194
195 let cookie = format!(
196 "{SESSION_COOKIE_NAME}={}; HttpOnly; SameSite=Lax; Path=/",
197 success.session_id
198 );
199
200 (
201 [(header::SET_COOKIE, cookie)],
202 Html(render_page(&RoutePage::signed_in())),
203 )
204 .into_response()
205}
206
207fn login_failure_response(error: &UiError) -> (StatusCode, RoutePage) {
213 match error {
214 UiError::NotAuthenticated | UiError::SessionExpired => {
215 (StatusCode::UNAUTHORIZED, RoutePage::login_failed())
216 }
217 UiError::BrokerConnection(_) => (
218 StatusCode::SERVICE_UNAVAILABLE,
219 RoutePage::login_broker_unavailable(),
220 ),
221 _ => (
222 StatusCode::BAD_GATEWAY,
223 RoutePage::login_broker_unavailable(),
224 ),
225 }
226}
227
228async fn post_logout<F, B>(
229 State(state): State<AdminRouterState<F, B>>,
230 request: Request<Body>,
231) -> impl IntoResponse
232where
233 F: AdminSeamFactory + Clone + Send + Sync + 'static,
234 for<'a> F::Reader<'a>: Send,
235 for<'a> F::Mutations<'a>: Send,
236 B: LoginBroker + Clone + Send + Sync + 'static,
237{
238 let (context, _) = match authenticated_mutation_body(&state, any_capability, request).await {
239 Ok(parsed) => parsed,
240 Err(response) => return *response,
241 };
242
243 if server_fns::logout(&context).is_err() {
244 return mutation_error_response(StatusCode::UNAUTHORIZED, "not authenticated");
245 }
246
247 let expired_cookie =
248 format!("{SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0");
249
250 (
251 StatusCode::OK,
252 [(header::SET_COOKIE, expired_cookie)],
253 Html(render_page(&RoutePage::login())),
254 )
255 .into_response()
256}
257
258async fn topics<F, B>(
259 State(state): State<AdminRouterState<F, B>>,
260 headers: HeaderMap,
261) -> Html<String>
262where
263 F: AdminSeamFactory + Clone + Send + Sync + 'static,
264 for<'a> F::Reader<'a>: Send,
265 B: LoginBroker + Clone + Send + Sync + 'static,
266{
267 let Some((context, operator)) = authenticated_page(&state, &headers) else {
268 return login_page();
269 };
270
271 if !Route::Topics.is_permitted(operator.capabilities()) {
272 return read_page(&RoutePage::topics(ReadRouteState::NotPermitted), &operator);
273 }
274
275 Html(match server_fns::list_topics_with_context(&context).await {
276 Ok(rows) => {
277 render_page_for_operator(&RoutePage::topics(ReadRouteState::Rows(rows)), &operator)
278 }
279 Err(UiError::NotAuthenticated) => render_login_page(),
280 Err(_) => {
281 render_page_for_operator(&RoutePage::topics(ReadRouteState::LoadFailed), &operator)
282 }
283 })
284}
285
286async fn groups<F, B>(
287 State(state): State<AdminRouterState<F, B>>,
288 headers: HeaderMap,
289) -> Html<String>
290where
291 F: AdminSeamFactory + Clone + Send + Sync + 'static,
292 for<'a> F::Reader<'a>: Send,
293 B: LoginBroker + Clone + Send + Sync + 'static,
294{
295 let Some((context, operator)) = authenticated_page(&state, &headers) else {
296 return login_page();
297 };
298
299 if !Route::Groups.is_permitted(operator.capabilities()) {
300 return read_page(&RoutePage::groups(ReadRouteState::NotPermitted), &operator);
301 }
302
303 Html(match server_fns::list_groups_with_context(&context).await {
304 Ok(rows) => {
305 render_page_for_operator(&RoutePage::groups(ReadRouteState::Rows(rows)), &operator)
306 }
307 Err(UiError::NotAuthenticated) => render_login_page(),
308 Err(_) => {
309 render_page_for_operator(&RoutePage::groups(ReadRouteState::LoadFailed), &operator)
310 }
311 })
312}
313
314async fn acls<F, B>(State(state): State<AdminRouterState<F, B>>, headers: HeaderMap) -> Html<String>
315where
316 F: AdminSeamFactory + Clone + Send + Sync + 'static,
317 for<'a> F::Reader<'a>: Send,
318 B: LoginBroker + Clone + Send + Sync + 'static,
319{
320 let Some((context, operator)) = authenticated_page(&state, &headers) else {
321 return login_page();
322 };
323
324 if !Route::Acls.is_permitted(operator.capabilities()) {
325 return read_page(&RoutePage::acls(ReadRouteState::NotPermitted), &operator);
326 }
327
328 Html(match server_fns::list_acls(&context).await {
329 Ok(rows) => {
330 render_page_for_operator(&RoutePage::acls(ReadRouteState::Rows(rows)), &operator)
331 }
332 Err(UiError::NotAuthenticated) => render_login_page(),
333 Err(_) => render_page_for_operator(&RoutePage::acls(ReadRouteState::LoadFailed), &operator),
334 })
335}
336
337async fn users<F, B>(
338 State(state): State<AdminRouterState<F, B>>,
339 headers: HeaderMap,
340) -> Html<String>
341where
342 F: AdminSeamFactory + Clone + Send + Sync + 'static,
343 for<'a> F::Reader<'a>: Send,
344 B: LoginBroker + Clone + Send + Sync + 'static,
345{
346 let Some((context, operator)) = authenticated_page(&state, &headers) else {
347 return login_page();
348 };
349
350 if !Route::Users.is_permitted(operator.capabilities()) {
351 return read_page(&RoutePage::users(ReadRouteState::NotPermitted), &operator);
352 }
353
354 Html(match server_fns::list_users(&context).await {
355 Ok(rows) => {
356 render_page_for_operator(&RoutePage::users(ReadRouteState::Rows(rows)), &operator)
357 }
358 Err(UiError::NotAuthenticated) => render_login_page(),
359 Err(_) => {
360 render_page_for_operator(&RoutePage::users(ReadRouteState::LoadFailed), &operator)
361 }
362 })
363}
364
365async fn quotas<F, B>(
366 State(state): State<AdminRouterState<F, B>>,
367 Query(lookup): Query<QuotaLookup>,
368 headers: HeaderMap,
369) -> Html<String>
370where
371 F: AdminSeamFactory + Clone + Send + Sync + 'static,
372 for<'a> F::Reader<'a>: Send,
373 B: LoginBroker + Clone + Send + Sync + 'static,
374{
375 let Some((context, operator)) = authenticated_page(&state, &headers) else {
376 return login_page();
377 };
378 let entity = lookup.requested_entity();
379
380 if !Route::Quotas.is_permitted(operator.capabilities()) {
381 return read_page(
382 &RoutePage::quotas_for_entity(ReadRouteState::NotPermitted, entity),
383 &operator,
384 );
385 }
386
387 Html(
388 match server_fns::list_quotas(&context, entity.clone()).await {
389 Ok(rows) => render_page_for_operator(
390 &RoutePage::quotas_for_entity(ReadRouteState::Rows(rows), entity),
391 &operator,
392 ),
393 Err(UiError::NotAuthenticated) => render_login_page(),
394 Err(_) => render_page_for_operator(
395 &RoutePage::quotas_for_entity(ReadRouteState::LoadFailed, entity),
396 &operator,
397 ),
398 },
399 )
400}
401
402async fn log_dirs<F, B>(
403 State(state): State<AdminRouterState<F, B>>,
404 headers: HeaderMap,
405) -> Html<String>
406where
407 F: AdminSeamFactory + Clone + Send + Sync + 'static,
408 for<'a> F::Reader<'a>: Send,
409 B: LoginBroker + Clone + Send + Sync + 'static,
410{
411 let Some((context, operator)) = authenticated_page(&state, &headers) else {
412 return login_page();
413 };
414
415 if !Route::LogDirs.is_permitted(operator.capabilities()) {
416 return read_page(
417 &RoutePage::log_dirs(ReadRouteState::NotPermitted),
418 &operator,
419 );
420 }
421
422 Html(
423 match server_fns::list_log_dirs_with_context(&context).await {
424 Ok(rows) => render_page_for_operator(
425 &RoutePage::log_dirs(ReadRouteState::Rows(rows)),
426 &operator,
427 ),
428 Err(UiError::NotAuthenticated) => render_login_page(),
429 Err(_) => render_page_for_operator(
430 &RoutePage::log_dirs(ReadRouteState::LoadFailed),
431 &operator,
432 ),
433 },
434 )
435}
436
437async fn post_create_topic<F, B>(
438 State(state): State<AdminRouterState<F, B>>,
439 request: Request<Body>,
440) -> impl IntoResponse
441where
442 F: AdminSeamFactory + Clone + Send + Sync + 'static,
443 for<'a> F::Reader<'a>: Send,
444 for<'a> F::Mutations<'a>: Send,
445 B: LoginBroker + Clone + Send + Sync + 'static,
446{
447 let (context, request) = match parse_authenticated_mutation_request::<CreateTopicFormDto, _, _>(
448 &state,
449 Capabilities::can_create_topics,
450 request,
451 )
452 .await
453 {
454 Ok(parsed) => parsed,
455 Err(response) => return *response,
456 };
457
458 mutation_result_response(server_fns::create_topic(&context, request).await)
459}
460
461async fn post_delete_topic<F, B>(
462 State(state): State<AdminRouterState<F, B>>,
463 request: Request<Body>,
464) -> impl IntoResponse
465where
466 F: AdminSeamFactory + Clone + Send + Sync + 'static,
467 for<'a> F::Reader<'a>: Send,
468 for<'a> F::Mutations<'a>: Send,
469 B: LoginBroker + Clone + Send + Sync + 'static,
470{
471 let (context, request) =
472 match parse_authenticated_mutation_request::<DeleteTopicRequestDto, _, _>(
473 &state,
474 Capabilities::can_delete_topics,
475 request,
476 )
477 .await
478 {
479 Ok(parsed) => parsed,
480 Err(response) => return *response,
481 };
482
483 mutation_result_response(server_fns::delete_topic(&context, request).await)
484}
485
486async fn post_create_partitions<F, B>(
487 State(state): State<AdminRouterState<F, B>>,
488 request: Request<Body>,
489) -> impl IntoResponse
490where
491 F: AdminSeamFactory + Clone + Send + Sync + 'static,
492 for<'a> F::Reader<'a>: Send,
493 for<'a> F::Mutations<'a>: Send,
494 B: LoginBroker + Clone + Send + Sync + 'static,
495{
496 let (context, request) = match parse_authenticated_mutation_request::<
497 CreatePartitionsRequestDto,
498 _,
499 _,
500 >(&state, Capabilities::can_alter_topics, request)
501 .await
502 {
503 Ok(parsed) => parsed,
504 Err(response) => return *response,
505 };
506
507 mutation_result_response(server_fns::create_partitions(&context, request).await)
508}
509
510async fn post_alter_configs<F, B>(
511 State(state): State<AdminRouterState<F, B>>,
512 request: Request<Body>,
513) -> impl IntoResponse
514where
515 F: AdminSeamFactory + Clone + Send + Sync + 'static,
516 for<'a> F::Reader<'a>: Send,
517 for<'a> F::Mutations<'a>: Send,
518 B: LoginBroker + Clone + Send + Sync + 'static,
519{
520 let (context, request) = match parse_authenticated_mutation_request::<AlterConfigFormDto, _, _>(
521 &state,
522 Capabilities::can_alter_topics,
523 request,
524 )
525 .await
526 {
527 Ok(parsed) => parsed,
528 Err(response) => return *response,
529 };
530
531 mutation_result_response(server_fns::alter_configs(&context, request).await)
532}
533
534async fn post_create_acl<F, B>(
535 State(state): State<AdminRouterState<F, B>>,
536 request: Request<Body>,
537) -> impl IntoResponse
538where
539 F: AdminSeamFactory + Clone + Send + Sync + 'static,
540 for<'a> F::Reader<'a>: Send,
541 for<'a> F::Mutations<'a>: Send,
542 B: LoginBroker + Clone + Send + Sync + 'static,
543{
544 let (context, request) = match parse_authenticated_mutation_request::<AclRequestDto, _, _>(
545 &state,
546 Capabilities::can_alter_acls,
547 request,
548 )
549 .await
550 {
551 Ok(parsed) => parsed,
552 Err(response) => return *response,
553 };
554
555 mutation_result_response(server_fns::create_acl(&context, request).await)
556}
557
558async fn post_delete_acl<F, B>(
559 State(state): State<AdminRouterState<F, B>>,
560 request: Request<Body>,
561) -> impl IntoResponse
562where
563 F: AdminSeamFactory + Clone + Send + Sync + 'static,
564 for<'a> F::Reader<'a>: Send,
565 for<'a> F::Mutations<'a>: Send,
566 B: LoginBroker + Clone + Send + Sync + 'static,
567{
568 let (context, request) = match parse_authenticated_mutation_request::<AclRequestDto, _, _>(
569 &state,
570 Capabilities::can_alter_acls,
571 request,
572 )
573 .await
574 {
575 Ok(parsed) => parsed,
576 Err(response) => return *response,
577 };
578
579 mutation_result_response(server_fns::delete_acl(&context, request).await)
580}
581
582async fn post_upsert_scram<F, B>(
583 State(state): State<AdminRouterState<F, B>>,
584 request: Request<Body>,
585) -> impl IntoResponse
586where
587 F: AdminSeamFactory + Clone + Send + Sync + 'static,
588 for<'a> F::Reader<'a>: Send,
589 for<'a> F::Mutations<'a>: Send,
590 B: LoginBroker + Clone + Send + Sync + 'static,
591{
592 let (context, request) = match parse_authenticated_mutation_request::<ScramUserUpsertDto, _, _>(
593 &state,
594 Capabilities::can_alter_users,
595 request,
596 )
597 .await
598 {
599 Ok(parsed) => parsed,
600 Err(response) => return *response,
601 };
602
603 mutation_result_response(server_fns::upsert_scram_sha512_user(&context, request).await)
604}
605
606async fn post_delete_scram<F, B>(
607 State(state): State<AdminRouterState<F, B>>,
608 request: Request<Body>,
609) -> impl IntoResponse
610where
611 F: AdminSeamFactory + Clone + Send + Sync + 'static,
612 for<'a> F::Reader<'a>: Send,
613 for<'a> F::Mutations<'a>: Send,
614 B: LoginBroker + Clone + Send + Sync + 'static,
615{
616 let (context, request) = match parse_authenticated_mutation_request::<ScramUserDeleteDto, _, _>(
617 &state,
618 Capabilities::can_alter_users,
619 request,
620 )
621 .await
622 {
623 Ok(parsed) => parsed,
624 Err(response) => return *response,
625 };
626
627 mutation_result_response(server_fns::delete_scram_user(&context, request).await)
628}
629
630async fn post_upsert_quota<F, B>(
631 State(state): State<AdminRouterState<F, B>>,
632 request: Request<Body>,
633) -> impl IntoResponse
634where
635 F: AdminSeamFactory + Clone + Send + Sync + 'static,
636 for<'a> F::Reader<'a>: Send,
637 for<'a> F::Mutations<'a>: Send,
638 B: LoginBroker + Clone + Send + Sync + 'static,
639{
640 let (context, request) = match parse_authenticated_mutation_request::<QuotaUpsertDto, _, _>(
641 &state,
642 Capabilities::can_alter_quotas,
643 request,
644 )
645 .await
646 {
647 Ok(parsed) => parsed,
648 Err(response) => return *response,
649 };
650
651 mutation_result_response(server_fns::upsert_quota(&context, request).await)
652}
653
654async fn post_delete_quota<F, B>(
655 State(state): State<AdminRouterState<F, B>>,
656 request: Request<Body>,
657) -> impl IntoResponse
658where
659 F: AdminSeamFactory + Clone + Send + Sync + 'static,
660 for<'a> F::Reader<'a>: Send,
661 for<'a> F::Mutations<'a>: Send,
662 B: LoginBroker + Clone + Send + Sync + 'static,
663{
664 let (context, request) = match parse_authenticated_mutation_request::<QuotaDeleteDto, _, _>(
665 &state,
666 Capabilities::can_alter_quotas,
667 request,
668 )
669 .await
670 {
671 Ok(parsed) => parsed,
672 Err(response) => return *response,
673 };
674
675 mutation_result_response(server_fns::delete_quota(&context, request).await)
676}
677
678async fn post_move_log_dir<F, B>(
679 State(state): State<AdminRouterState<F, B>>,
680 request: Request<Body>,
681) -> impl IntoResponse
682where
683 F: AdminSeamFactory + Clone + Send + Sync + 'static,
684 for<'a> F::Reader<'a>: Send,
685 for<'a> F::Mutations<'a>: Send,
686 B: LoginBroker + Clone + Send + Sync + 'static,
687{
688 let (context, request) =
689 match parse_authenticated_mutation_request::<LogDirMoveRequestDto, _, _>(
690 &state,
691 Capabilities::can_alter_log_dirs,
692 request,
693 )
694 .await
695 {
696 Ok(parsed) => parsed,
697 Err(response) => return *response,
698 };
699
700 mutation_result_response(server_fns::move_log_dir(&context, request).await)
701}
702
703async fn authenticated_mutation_body<F, B>(
711 state: &AdminRouterState<F, B>,
712 required_capability: fn(Capabilities) -> bool,
713 request: Request<Body>,
714) -> Result<(ServerFunctionContext<'_, F>, MutationBody), Box<axum::response::Response>> {
715 let Some((context, record)) = authenticated_session(state, request.headers()) else {
716 return Err(Box::new(mutation_error_response(
717 StatusCode::UNAUTHORIZED,
718 "not authenticated",
719 )));
720 };
721
722 if !required_capability(record.capabilities) {
723 return Err(Box::new(mutation_error_response(
724 StatusCode::FORBIDDEN,
725 "not permitted",
726 )));
727 }
728
729 let content_type = request_content_type(request.headers());
730 let header_token = request
731 .headers()
732 .get(CSRF_HEADER_NAME)
733 .and_then(|value| value.to_str().ok())
734 .unwrap_or_default()
735 .to_string();
736
737 let body = to_bytes(
738 request.into_body(),
739 state.app.cfg.mutation_json_body_limit.bytes_usize(),
740 )
741 .await
742 .map_err(|_| {
743 Box::new(mutation_error_response(
744 StatusCode::PAYLOAD_TOO_LARGE,
745 "request body too large",
746 ))
747 })?;
748
749 let body = match content_type.as_deref() {
750 Some("application/json") => {
751 if !record.csrf_token.matches(&header_token) {
752 return Err(Box::new(csrf_error_response()));
753 }
754
755 MutationBody::Json(body)
756 }
757 Some("application/x-www-form-urlencoded") => {
758 let Ok(fields) = serde_urlencoded::from_bytes::<Vec<(String, String)>>(&body) else {
759 return Err(Box::new(mutation_error_response(
760 StatusCode::BAD_REQUEST,
761 "invalid form request",
762 )));
763 };
764 let supplied = fields
765 .iter()
766 .find(|(name, _)| name == CSRF_FIELD_NAME)
767 .map_or("", |(_, value)| value.as_str());
768
769 if !record.csrf_token.matches(supplied) {
770 return Err(Box::new(csrf_error_response()));
771 }
772
773 let remaining: Vec<(String, String)> = fields
774 .into_iter()
775 .filter(|(name, _)| name != CSRF_FIELD_NAME)
776 .collect();
777 let Ok(encoded) = serde_urlencoded::to_string(&remaining) else {
778 return Err(Box::new(mutation_error_response(
779 StatusCode::BAD_REQUEST,
780 "invalid form request",
781 )));
782 };
783
784 MutationBody::Form(encoded)
785 }
786 _ => {
787 return Err(Box::new(mutation_error_response(
788 StatusCode::UNSUPPORTED_MEDIA_TYPE,
789 "mutations take application/json or application/x-www-form-urlencoded",
790 )));
791 }
792 };
793
794 Ok((context, body))
795}
796
797async fn parse_authenticated_mutation_request<T, F, B>(
798 state: &AdminRouterState<F, B>,
799 required_capability: fn(Capabilities) -> bool,
800 request: Request<Body>,
801) -> Result<(ServerFunctionContext<'_, F>, T::Request), Box<axum::response::Response>>
802where
803 T: MutationForm,
804{
805 let (context, body) = authenticated_mutation_body(state, required_capability, request).await?;
806
807 let request = match body {
808 MutationBody::Json(bytes) => parse_json_request::<T::Request>(&bytes).map_err(|_| {
809 Box::new(mutation_error_response(
810 StatusCode::BAD_REQUEST,
811 "invalid JSON request",
812 ))
813 })?,
814 MutationBody::Form(encoded) => {
815 let form = serde_urlencoded::from_str::<T>(&encoded).map_err(|_| {
816 Box::new(mutation_error_response(
817 StatusCode::BAD_REQUEST,
818 "invalid form request",
819 ))
820 })?;
821
822 form.into_request().map_err(|reason| {
823 Box::new(mutation_error_response(StatusCode::BAD_REQUEST, &reason))
824 })?
825 }
826 };
827
828 Ok((context, request))
829}
830
831const fn any_capability(_: Capabilities) -> bool {
833 true
834}
835
836fn request_content_type(headers: &HeaderMap) -> Option<String> {
838 let value = headers.get(header::CONTENT_TYPE)?.to_str().ok()?;
839 let media_type = value.split(';').next()?.trim().to_ascii_lowercase();
840
841 Some(media_type)
842}
843
844fn parse_json_request<T: DeserializeOwned>(body: &[u8]) -> Result<T, serde_json::Error> {
845 serde_json::from_slice(body)
846}
847
848fn csrf_error_response() -> axum::response::Response {
849 mutation_error_response(StatusCode::FORBIDDEN, "missing or stale CSRF token")
850}
851
852fn mutation_result_response(
853 result: Result<Vec<ResourceOutcome>, UiError>,
854) -> axum::response::Response {
855 match result {
856 Ok(outcomes) => (StatusCode::OK, mutation_outcome_text(&outcomes)).into_response(),
857 Err(error) => mutation_error_response(mutation_error_status(&error), &error.to_string()),
858 }
859}
860
861fn mutation_error_status(error: &UiError) -> StatusCode {
867 match error {
868 UiError::NotAuthenticated | UiError::SessionExpired => StatusCode::UNAUTHORIZED,
869 UiError::NotPermitted => StatusCode::FORBIDDEN,
870 UiError::InvalidRequest(_) => StatusCode::BAD_REQUEST,
871 UiError::BrokerConnection(_) | UiError::Broker { .. } => StatusCode::BAD_GATEWAY,
872 UiError::Admin(_) => StatusCode::INTERNAL_SERVER_ERROR,
873 }
874}
875
876fn mutation_error_response(status: StatusCode, message: &str) -> axum::response::Response {
877 (status, format!("status=error\nmessage={message}\n")).into_response()
878}
879
880fn mutation_outcome_text(outcomes: &[ResourceOutcome]) -> String {
881 let mut text = String::from("status=ok\n");
882
883 for outcome in outcomes {
884 text.push_str("resource=");
885 text.push_str(&outcome.resource);
886 if let Some(error) = &outcome.error {
887 text.push_str("\tstatus=error\terror_code=");
888 text.push_str(&error.code.to_string());
889 text.push_str("\terror_name=");
890 text.push_str(&error.name);
891 } else {
892 text.push_str("\tstatus=ok");
893 }
894 text.push('\n');
895 }
896
897 text
898}
899
900fn login_page() -> Html<String> {
901 Html(render_login_page())
902}
903
904fn read_page(page: &RoutePage, operator: &OperatorView) -> Html<String> {
905 Html(render_page_for_operator(page, operator))
906}
907
908fn render_login_page() -> String {
909 render_page(&RoutePage::login())
910}
911
912fn authenticated_session<'a, F, B>(
915 state: &'a AdminRouterState<F, B>,
916 headers: &HeaderMap,
917) -> Option<(ServerFunctionContext<'a, F>, SessionRecord)> {
918 let raw_session_id = session_cookie(headers)?;
919 let record =
920 server_fns::session_record_with_store(&state.app.sessions, Some(raw_session_id)).ok()?;
921
922 Some((
923 ServerFunctionContext::new(
924 &state.app.cfg,
925 &state.app.sessions,
926 Some(raw_session_id),
927 &state.seam_factory,
928 ),
929 record,
930 ))
931}
932
933fn authenticated_page<'a, F, B>(
935 state: &'a AdminRouterState<F, B>,
936 headers: &HeaderMap,
937) -> Option<(ServerFunctionContext<'a, F>, OperatorView)> {
938 let (context, record) = authenticated_session(state, headers)?;
939 let operator = OperatorView::new(
940 record.capabilities,
941 record.csrf_token.expose_for_form().to_string(),
942 );
943
944 Some((context, operator))
945}
946
947fn session_cookie(headers: &HeaderMap) -> Option<&str> {
948 let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
949
950 cookie_header.split(';').find_map(|cookie| {
951 let (name, value) = cookie.trim().split_once('=')?;
952 (name == SESSION_COOKIE_NAME).then_some(value)
953 })
954}