Skip to main content

krabka_admin_ui/
server.rs

1//! HTTP server helpers for the admin UI.
2
3use 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
70/// The body of a mutation request, and the form it arrived in.
71enum MutationBody {
72    /// A JSON body, as the UI's own API takes it.
73    Json(Bytes),
74    /// An `application/x-www-form-urlencoded` body, as a rendered form posts
75    /// it. The CSRF field is already removed.
76    Form(String),
77}
78
79/// What a quota page shows: the named user, or the signed-in operator.
80#[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
207/// Separates a rejected sign-in from a broker that did not answer.
208///
209/// Monitoring reads the status code, so a cluster outage must not arrive as
210/// `401`: an operator who reads "authentication failed" retypes a password that
211/// was never the problem.
212fn 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
703/// Authenticates a mutation, proves the operator asked for it, and returns the
704/// body.
705///
706/// The session cookie alone does not prove intent: a browser attaches it to a
707/// cross-origin form post as well. So a mutation must arrive with an accepted
708/// content type and must repeat the session's CSRF token, which only a page
709/// this server rendered can know.
710async 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
831/// Every signed-in operator may sign out, whatever their ACLs say.
832const fn any_capability(_: Capabilities) -> bool {
833    true
834}
835
836/// The media type of the request, without its parameters.
837fn 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
861/// The status that matches what went wrong.
862///
863/// A request the client can correct is a client error; a broker that did not
864/// answer is an upstream failure; only an unclassified admin failure is this
865/// server's own.
866fn 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
912/// The session behind the request's cookie, with the context that reaches the
913/// broker.
914fn 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
933/// The same, with the record turned into what the shell renders from.
934fn 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}