Skip to main content

cratefield_ui/
lib.rs

1//! `cratefield-ui` renders the harness's UI surface (ADR 0010) as HTML from
2//! inside the Worker: full pages at `/ui/<module>/<action>`, the same as a
3//! fragment with `?fragment=1`, landing pages at
4//! `/ui/<module>/<action>/<page>`, and the base stylesheet at
5//! `/ui/cf.css`. A form posts to its own `/ui` route; the handler turns
6//! the form into the JSON the module accepts and dispatches it
7//! **in-process** to the `/v1` router, then renders the `202`, the
8//! `problem+json` or the `303`. Modules stay JSON-only.
9//!
10//! Mount with `Harness::builder().ui(cratefield_ui::Ui::default())`.
11//!
12//! Web-agnostic like every other crate: no `worker`, `tokio` or
13//! `std::fs`; `maud` builds strings, and the wasm build of
14//! `examples/venture` proves it.
15
16#![forbid(unsafe_code)]
17
18mod admin;
19mod fields;
20pub mod render;
21pub mod spec;
22
23use std::collections::BTreeMap;
24use std::sync::Arc;
25
26use axum::Router;
27use axum::body::Body;
28use axum::extract::{Path, Query, State};
29use axum::http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header};
30use axum::response::{Html, IntoResponse, Response};
31use axum::routing::get;
32use cratefield_core::{
33    Action, Audience, Outcome, Problem, Scope, UiContext, UiMount, require_admin,
34};
35use serde_json::Value;
36use tower::ServiceExt;
37
38// `Option_` types `Widget::Select`'s choices, so a caller reading a
39// field's options had a value whose type it could not name.
40pub use fields::{Field, JsonType, Option_, Values, Widget, fields_of, form_to_json, humanize};
41pub use spec::{
42    ActionSpec, FieldSpec, ModuleSpec, PageCopy, Theme, UI_SPEC_KEY, UI_SPEC_VERSION, UiSpec,
43};
44
45/// The base stylesheet served at `/ui/cf.css`: `@layer cf`, `--cf-*`
46/// custom properties, nothing that an unlayered venture rule cannot beat.
47pub const CF_CSS: &str = include_str!("../assets/cf.css");
48/// The embed served at `/ui/cf.js` (issue #73); empty until then.
49pub const CF_JS: &str = include_str!("../assets/cf.js");
50/// The reference push service worker served at `/ui/sw-push.js` (issue
51/// #183).
52///
53/// Served to be **read and copied**, not registered from here: a service
54/// worker may only be registered from the origin of the page registering
55/// it, and a venture's site is rarely the API origin. `cf.push.subscribe`
56/// registers `/sw.js` on the page's own origin by default.
57pub const CF_SW_PUSH: &str = include_str!("../assets/sw-push.js");
58
59/// Config key for the Turnstile **site** key (public; the secret stays
60/// with the adapter). Without it a captcha action renders no widget, and
61/// the module's captcha check answers as it does for any missing token.
62pub const TURNSTILE_SITE_KEY: &str = "TURNSTILE_SITE_KEY";
63
64/// Renderer configuration; mounted with `HarnessBuilder::ui`.
65#[derive(Debug, Clone, Default)]
66pub struct Ui {
67    theme_css: Option<String>,
68    spec: UiSpec,
69}
70
71impl Ui {
72    #[must_use]
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// A stylesheet linked after `cf.css` on every page: the venture's
78    /// theme. Absolute URL or a path on the API origin. The spec's
79    /// `theme.css_url` wins when both are set.
80    #[must_use]
81    pub fn theme_css(mut self, url: impl Into<String>) -> Self {
82        self.theme_css = Some(url.into());
83        self
84    }
85
86    /// The venture's `UiSpec` as JSON (`include_str!("../ui.json")`).
87    /// Parsed here; validated against the surface by `Harness::build`.
88    /// A runtime `UI_SPEC` in config replaces it without a rebuild.
89    ///
90    /// # Errors
91    ///
92    /// The parse error, with its path.
93    pub fn from_spec(json: &str) -> Result<Self, String> {
94        Ok(Self::new().spec(UiSpec::parse(json)?))
95    }
96
97    #[must_use]
98    pub fn spec(mut self, spec: UiSpec) -> Self {
99        self.spec = spec;
100        self
101    }
102}
103
104impl UiMount for Ui {
105    fn validate(
106        &self,
107        surface: &cratefield_core::SurfaceDocument,
108        errors: &mut cratefield_core::ConfigError,
109    ) {
110        if let Err(problems) = self.spec.validate(surface) {
111            for problem in problems {
112                errors.push(format!("ui spec: {problem}"));
113            }
114        }
115    }
116
117    fn describe(&self) -> Option<Value> {
118        (self.spec != UiSpec::default())
119            .then(|| serde_json::to_value(&self.spec).ok())
120            .flatten()
121    }
122
123    fn router(&self, ctx: UiContext) -> Router {
124        // A runtime spec replaces the built-in one; a broken runtime spec
125        // takes the whole UI down loudly rather than rendering from half
126        // of it, and says why on every request.
127        let runtime = ctx
128            .config
129            .get(UI_SPEC_KEY)
130            .filter(|json| !json.trim().is_empty())
131            .map(|json| {
132                UiSpec::parse(&json).and_then(|spec| {
133                    spec.validate(&ctx.surface.built())
134                        .map(|()| spec)
135                        .map_err(|problems| {
136                            format!("ui spec ({UI_SPEC_KEY}): {}", problems.join("; "))
137                        })
138                })
139            });
140        let spec = match runtime {
141            Some(Ok(spec)) => spec,
142            Some(Err(problem)) => {
143                tracing::error!(%problem, "runtime UI spec rejected; /ui is disabled");
144                return Router::new().fallback(move |scope: Scope| {
145                    let problem = problem.clone();
146                    async move {
147                        Problem::internal()
148                            .with_detail(problem)
149                            .instance(&scope.request_id)
150                    }
151                });
152            }
153            None => self.spec.clone(),
154        };
155        let state = Arc::new(UiState {
156            ui: self.clone(),
157            spec,
158            ctx,
159        });
160        Router::new()
161            .route("/theme.css", get(theme_css))
162            .route("/spec.json", get(spec_json))
163            .route("/cf.css", get(css))
164            .route("/cf.js", get(js))
165            .route("/sw-push.js", get(sw_push))
166            .route("/admin", get(admin::index))
167            .route(
168                "/admin/login",
169                get(admin::login_get).post(admin::login_post),
170            )
171            .route("/admin/logout", axum::routing::post(admin::logout))
172            .route(
173                "/admin/{module}/{action}",
174                get(admin::page_get).post(admin::page_post),
175            )
176            .route("/{module}/{action}", get(page_get).post(page_post))
177            .route("/{module}/{action}/{landing}", get(landing))
178            .with_state(state)
179    }
180}
181
182struct UiState {
183    ui: Ui,
184    /// The effective spec: runtime `UI_SPEC` if set, else the builder's.
185    spec: UiSpec,
186    ctx: UiContext,
187}
188
189async fn theme_css(State(state): State<Arc<UiState>>) -> impl IntoResponse {
190    (
191        [
192            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
193            (header::CACHE_CONTROL, "public, max-age=300"),
194        ],
195        state.spec.theme_css(),
196    )
197}
198
199/// The effective spec, for tooling and the control plane to read back.
200async fn spec_json(State(state): State<Arc<UiState>>) -> impl IntoResponse {
201    (
202        [
203            (header::CONTENT_TYPE, "application/json"),
204            (header::CACHE_CONTROL, "no-cache"),
205        ],
206        serde_json::to_string(&state.spec).unwrap_or_else(|_| "{}".to_owned()),
207    )
208}
209
210async fn css() -> impl IntoResponse {
211    (
212        [
213            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
214            (header::CACHE_CONTROL, "public, max-age=300"),
215        ],
216        CF_CSS,
217    )
218}
219
220async fn js() -> impl IntoResponse {
221    (
222        [
223            (header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
224            (header::CACHE_CONTROL, "public, max-age=300"),
225        ],
226        CF_JS,
227    )
228}
229
230/// The reference push service worker.
231///
232/// `Service-Worker-Allowed: /` is the header that makes the file usable
233/// as more than a listing. A worker's default scope is the directory it
234/// is served from, so this one would be confined to `/ui/`; the header is
235/// how a server widens that, and a venture that serves the copy from a
236/// path rather than its site root needs the same header on it. Without
237/// it, `register(url, { scope: "/" })` is rejected — by the browser, with
238/// a `SecurityError` that says nothing about which side is wrong.
239async fn sw_push() -> impl IntoResponse {
240    (
241        [
242            (header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
243            (header::CACHE_CONTROL, "public, max-age=300"),
244            (
245                header::HeaderName::from_static("service-worker-allowed"),
246                "/",
247            ),
248        ],
249        CF_SW_PUSH,
250    )
251}
252
253/// Looks an action up; admin actions are not served here until the admin
254/// UI (issue #74) exists, so they are `404` like an unknown one.
255fn find_action<'a>(
256    surface: &'a cratefield_core::SurfaceDocument,
257    module: &str,
258    action: &str,
259) -> Option<&'a Action> {
260    surface
261        .modules
262        .iter()
263        .find(|m| m.name == module)?
264        .surface
265        .actions
266        .iter()
267        .find(|a| a.name == action && a.audience != Audience::Admin)
268}
269
270fn not_found(scope: &Scope, what: &str) -> Response {
271    Problem::not_found()
272        .with_detail(format!("no UI for {what}"))
273        .instance(&scope.request_id)
274        .into_response()
275}
276
277/// Query switches the renderer consumes rather than pre-fills:
278/// `fragment=1` and `hide=<field,field>` (a page that supplies a value
279/// and does not want the visitor to change it, which is what the embed
280/// sends for every attribute-supplied field).
281struct Switches {
282    fragment: bool,
283    hide: Vec<String>,
284}
285
286fn split_switches(mut query: Values) -> (Values, Switches) {
287    let fragment = query
288        .remove("fragment")
289        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes"));
290    let hide = query
291        .remove("hide")
292        .map(|list| {
293            list.split(',')
294                .map(str::trim)
295                .filter(|f| !f.is_empty())
296                .map(str::to_owned)
297                .collect()
298        })
299        .unwrap_or_default();
300    (query, Switches { fragment, hide })
301}
302
303fn split_fragment(query: Values) -> (Values, bool) {
304    let (values, switches) = split_switches(query);
305    (values, switches.fragment)
306}
307
308/// Fields the page asked to hide render as hidden inputs (with the
309/// supplied value) instead of controls.
310fn apply_hide(fields: &mut [Field], hide: &[String]) {
311    for field in fields.iter_mut() {
312        if hide.iter().any(|h| h == &field.name) {
313            field.widget = Widget::Hidden;
314        }
315    }
316}
317
318/// `GET /ui/<module>/<action>`: the form for a `POST` action (query
319/// parameters pre-fill and hide fields, which is how a page passes
320/// `product=` or `ref=`), or the result of a `GET` action dispatched with
321/// the same query (a status page, a signed link).
322async fn page_get(
323    scope: Scope,
324    State(state): State<Arc<UiState>>,
325    Path((module, action)): Path<(String, String)>,
326    Query(query): Query<Values>,
327    headers: HeaderMap,
328) -> Response {
329    let surface = state.ctx.surface.current().await;
330    let Some(spec) = find_action(&surface, &module, &action) else {
331        return not_found(&scope, &format!("{module}/{action}"));
332    };
333    let (values, switches) = split_switches(query);
334    let fragment = switches.fragment;
335    if spec.method == Method::POST {
336        let mut fields = spec
337            .input
338            .as_ref()
339            .map(|s| fields_of(s.as_value()))
340            .unwrap_or_default();
341        apply_hide(&mut fields, &switches.hide);
342        let body = render_form(&state, spec, &module, &fields, &values, &[]);
343        return respond(&state, fragment, body);
344    }
345    let query_string = serde_urlencoded_encode(&values);
346    let response = dispatch(&state, &scope, &headers, spec, &module, &query_string, None).await;
347    render_result(&state, &scope, spec, &module, fragment, response, None).await
348}
349
350/// `POST /ui/<module>/<action>`: the form comes back, becomes JSON, and
351/// is dispatched in-process; the module's answer is rendered.
352async fn page_post(
353    scope: Scope,
354    State(state): State<Arc<UiState>>,
355    Path((module, action)): Path<(String, String)>,
356    Query(query): Query<Values>,
357    headers: HeaderMap,
358    axum::Form(form): axum::Form<Vec<(String, String)>>,
359) -> Response {
360    let surface = state.ctx.surface.current().await;
361    let Some(spec) = find_action(&surface, &module, &action) else {
362        return not_found(&scope, &format!("{module}/{action}"));
363    };
364    if spec.method != Method::POST {
365        return Problem::not_found()
366            .with_detail(format!("{module}/{action} does not take a form"))
367            .instance(&scope.request_id)
368            .into_response();
369    }
370    let (_, switches) = split_switches(query);
371    let fragment = switches.fragment;
372    let mut fields = spec
373        .input
374        .as_ref()
375        .map(|s| fields_of(s.as_value()))
376        .unwrap_or_default();
377    apply_hide(&mut fields, &switches.hide);
378    let mut values: Values = form.into_iter().collect();
379    // Turnstile posts its token under its own name; the module wants it
380    // as `captchaToken`.
381    if let Some(token) = values.remove("cf-turnstile-response") {
382        values.insert("captchaToken".to_owned(), token);
383    }
384    let json = Value::Object(form_to_json(&fields, &values));
385    let response = dispatch(&state, &scope, &headers, spec, &module, "", Some(json)).await;
386    render_result(
387        &state,
388        &scope,
389        spec,
390        &module,
391        fragment,
392        response,
393        Some((fields, values)),
394    )
395    .await
396}
397
398/// `GET /ui/<module>/<action>/<landing>`: where a module's signed links
399/// send the browser when the UI is mounted (`done`, `expired`).
400async fn landing(
401    scope: Scope,
402    State(state): State<Arc<UiState>>,
403    Path((module, action, landing)): Path<(String, String, String)>,
404    Query(query): Query<Values>,
405) -> Response {
406    let surface = state.ctx.surface.current().await;
407    if find_action(&surface, &module, &action).is_none() {
408        return not_found(&scope, &format!("{module}/{action}"));
409    }
410    let (_, fragment) = split_fragment(query);
411    let (tone, title, message) = match (action.as_str(), landing.as_str()) {
412        ("confirm", "done") => (
413            "success",
414            "Confirmed".to_owned(),
415            "Your email address is confirmed.".to_owned(),
416        ),
417        ("unsubscribe", "done") => (
418            "success",
419            "Unsubscribed".to_owned(),
420            "You will not receive further email from us.".to_owned(),
421        ),
422        (_, "expired") => (
423            "warning",
424            "Link expired".to_owned(),
425            "This link is no longer valid. Please start again.".to_owned(),
426        ),
427        (_, "done") => ("success", "Done".to_owned(), "Done.".to_owned()),
428        _ => return not_found(&scope, &format!("{module}/{action}/{landing}")),
429    };
430    let copy = state
431        .spec
432        .action(&module, &action)
433        .and_then(|c| c.pages.get(&landing));
434    let title = copy.and_then(|c| c.title.clone()).unwrap_or(title);
435    let message = copy.and_then(|c| c.message.clone()).unwrap_or(message);
436    let body = render::notice(&module, &action, tone, &title, &message);
437    respond(&state, fragment, (title, body, false))
438}
439
440fn render_form(
441    state: &UiState,
442    spec: &Action,
443    module: &str,
444    fields: &[Field],
445    values: &Values,
446    errors: &[(String, String)],
447) -> (String, maud::Markup, bool) {
448    let site_key = (spec.captcha && state.ctx.captcha_configured)
449        .then(|| state.ctx.config.get(TURNSTILE_SITE_KEY))
450        .flatten()
451        .filter(|key| !key.is_empty());
452    let post_to = format!("/ui/{module}/{}", spec.name);
453    let copy = state.spec.action(module, &spec.name);
454    let title = copy
455        .and_then(|c| c.title.clone())
456        .unwrap_or_else(|| humanize(&spec.name));
457    let submit = copy
458        .and_then(|c| c.submit.clone())
459        .unwrap_or_else(|| humanize(&spec.name));
460    let fields = apply_spec(fields, copy);
461    let body = render::form(&render::FormSpec {
462        module,
463        action: &spec.name,
464        post_to: &post_to,
465        intro: copy.and_then(|c| c.intro.as_deref()),
466        fields: &fields,
467        values,
468        errors,
469        captcha_site_key: site_key.as_deref(),
470        submit_label: &submit,
471    });
472    (title, body, site_key.is_some())
473}
474
475/// Copy overrides, hidden flags and order from the spec, over the
476/// fields the schema produced.
477fn apply_spec(fields: &[Field], copy: Option<&ActionSpec>) -> Vec<Field> {
478    let Some(copy) = copy else {
479        return fields.to_vec();
480    };
481    let mut out: Vec<Field> = fields
482        .iter()
483        .map(|field| {
484            let mut field = field.clone();
485            if let Some(over) = copy.fields.get(&field.name) {
486                if let Some(label) = &over.label {
487                    field.label = label.clone();
488                }
489                if over.placeholder.is_some() {
490                    field.placeholder = over.placeholder.clone();
491                }
492                if over.help.is_some() {
493                    field.help = over.help.clone();
494                }
495                if over.hidden {
496                    field.widget = Widget::Hidden;
497                }
498            }
499            field
500        })
501        .collect();
502    if !copy.order.is_empty() {
503        let rank = |name: &str| {
504            copy.order
505                .iter()
506                .position(|n| n == name)
507                .unwrap_or(copy.order.len())
508        };
509        out.sort_by_key(|field| rank(&field.name));
510    }
511    out
512}
513
514/// Whether an action is admin-gated: declared for the `Admin` audience,
515/// or served under `/admin/` no matter what the surface declares.
516///
517/// Security invariant (issue #130): the audience filter in [`find_action`]
518/// and the public subset of `/__surface` are *visibility*, not
519/// authorization. Execution re-derives the gate from the path as well as
520/// the audience, and [`dispatch`] checks it with the same
521/// [`require_admin`] the target route uses.
522///
523/// Since #131 a sidecar's document is validated against its mount before
524/// it merges, so a public audience over an admin path is refused there
525/// and cannot reach the renderer. The path half of this predicate is
526/// therefore defence in depth rather than the only gate — kept because a
527/// hole in merge validation must not become an authorization bypass.
528fn is_admin_gated(spec: &Action) -> bool {
529    spec.audience == Audience::Admin || spec.path == "/admin" || spec.path.starts_with("/admin/")
530}
531
532/// Builds the internal request and sends it through the `/v1` router.
533/// The caller's `Scope` rides along in the extensions (the scope layer
534/// sits above `/v1` and will not run again), and the headers that
535/// identify the caller are forwarded so rate limiting, captcha and admin
536/// checks see the browser, not the renderer.
537///
538/// An admin-gated action ([`is_admin_gated`]) is authorized here, before
539/// anything is sent: [`require_admin`] over the forwarded headers is the
540/// same check the target route runs, so in-process dispatch cannot be a
541/// path around it. On failure the problem the direct request would have
542/// answered (`401`/`403`) is returned and the module is never invoked.
543async fn dispatch(
544    state: &UiState,
545    scope: &Scope,
546    headers: &HeaderMap,
547    spec: &Action,
548    module: &str,
549    query: &str,
550    json: Option<Value>,
551) -> Response {
552    if is_admin_gated(spec)
553        && let Err(problem) = require_admin(&*state.ctx.config, headers)
554    {
555        return problem.instance(&scope.request_id).into_response();
556    }
557    // An action at `/` is the nest root: `/v1/<module>`, no trailing
558    // slash, or axum's nesting answers 404.
559    let mut uri = format!("/v1/{module}{}", spec.path.trim_end_matches('/'));
560    if !query.is_empty() {
561        uri.push('?');
562        uri.push_str(query);
563    }
564    let mut builder = Request::builder().method(spec.method.clone()).uri(uri);
565    for name in [
566        "cf-connecting-ip",
567        "x-forwarded-for",
568        "x-real-ip",
569        "user-agent",
570        "accept-language",
571        "authorization",
572        "x-request-id",
573    ] {
574        if let Some(value) = headers.get(name) {
575            builder = builder.header(name, value.clone());
576        }
577    }
578    let request = match json {
579        Some(json) => builder
580            .header(header::CONTENT_TYPE, "application/json")
581            .body(Body::from(json.to_string())),
582        None => builder.body(Body::empty()),
583    };
584    let mut request = match request {
585        Ok(request) => request,
586        Err(err) => {
587            tracing::error!(error = %err, "ui dispatch could not build the internal request");
588            return Problem::internal()
589                .instance(&scope.request_id)
590                .into_response();
591        }
592    };
593    request.extensions_mut().insert(scope.clone());
594    match state.ctx.api.clone().oneshot(request).await {
595        Ok(response) => response,
596        Err(never) => match never {},
597    }
598}
599
600/// Renders whatever the module answered: `2xx` as the outcome the surface
601/// declares, `3xx` passed to the browser, a problem as the form with the
602/// error on its field (or above the form when it names none).
603async fn render_result(
604    state: &UiState,
605    scope: &Scope,
606    spec: &Action,
607    module: &str,
608    fragment: bool,
609    response: Response,
610    form: Option<(Vec<Field>, Values)>,
611) -> Response {
612    let status = response.status();
613    if status.is_redirection() {
614        return response;
615    }
616    // The problem rides in the extensions (issue #557): the body's `type`
617    // stays context-free (`about:blank`) until a venture's router names
618    // it, and this in-process dispatch sees no such layer.
619    let carried = response.extensions().get::<Problem>().cloned();
620    let (_parts, body) = response.into_parts();
621    let bytes = axum::body::to_bytes(body, 1024 * 1024)
622        .await
623        .unwrap_or_default();
624    let json: Option<Value> = serde_json::from_slice(&bytes).ok();
625    // Status only: a body may carry an address, and the logging policy
626    // (`cratefield_core::RedactingVisitor`) is not applied to free text.
627    tracing::debug!(status = %status, "ui dispatch answered");
628
629    if status.is_success() {
630        let copy = state.spec.action(module, &spec.name);
631        let title = copy
632            .and_then(|c| c.title.clone())
633            .unwrap_or_else(|| humanize(&spec.name));
634        let body = match &spec.outcome {
635            Outcome::Json => {
636                render::status(module, &spec.name, json.as_ref().unwrap_or(&Value::Null))
637            }
638            Outcome::Accepted { message } => {
639                let message = copy.and_then(|c| c.success.as_deref()).unwrap_or(message);
640                render::notice(module, &spec.name, "success", &title, message)
641            }
642            Outcome::Redirect => render::notice(module, &spec.name, "success", &title, "Done."),
643        };
644        return respond(state, fragment, (title, body, false));
645    }
646
647    let problem = json.as_ref();
648    let detail = problem
649        .and_then(|p| p.get("detail").and_then(Value::as_str))
650        .map(str::to_owned);
651    let title_text = problem
652        .and_then(|p| p.get("title").and_then(Value::as_str))
653        .map(str::to_owned);
654    let message = match status {
655        StatusCode::TOO_MANY_REQUESTS => "Too many attempts. Please try again later.".to_owned(),
656        // A 503 names its cause in the title (mail not configured, not
657        // ready); a 500 never says more than the request id.
658        StatusCode::SERVICE_UNAVAILABLE => format!(
659            "{}. Please try again later. Request id {}.",
660            title_text.as_deref().unwrap_or("Service unavailable"),
661            scope.request_id
662        ),
663        s if s.is_server_error() => format!(
664            "Something went wrong on our side. Request id {}.",
665            scope.request_id
666        ),
667        _ => detail
668            .clone()
669            .or(title_text)
670            .unwrap_or_else(|| "The request was not accepted.".to_owned()),
671    };
672    let Some((fields, mut values)) = form else {
673        let body = render::notice(module, &spec.name, "error", &humanize(&spec.name), &message);
674        let mut out = respond(state, fragment, (humanize(&spec.name), body, false));
675        *out.status_mut() = status;
676        return out;
677    };
678    // The carried problem's slug is the stable part of its `type` (issue
679    // #557); the body's `type` only answers for responses without one.
680    let slug = carried
681        .map(|p| p.slug.to_owned())
682        .or_else(|| {
683            problem
684                .and_then(|p| p.get("type").and_then(Value::as_str))
685                .and_then(|t| t.rsplit('/').next())
686                .map(str::to_owned)
687        })
688        .unwrap_or_default();
689    let field = detail
690        .as_deref()
691        .and_then(|d| attribute_to_field(d, &fields))
692        .or_else(|| attribute_by_slug(&slug, &fields))
693        .unwrap_or_default();
694    // "email: email must contain exactly one @" next to the email field
695    // reads twice; drop the prefix the module put there for API clients.
696    let message = message
697        .strip_prefix(&format!("{field}: "))
698        .filter(|_| !field.is_empty())
699        .map_or(message.clone(), str::to_owned);
700    let errors = vec![(field, message)];
701    // A captcha token is single-use: the widget issues a fresh one.
702    values.remove("captchaToken");
703    let mut out = respond(
704        state,
705        fragment,
706        render_form(state, spec, module, &fields, &values, &errors),
707    );
708    // Keep the browser on the form: a validation error is the form's
709    // state, not a new page, so `400` becomes `422` like a validating
710    // handler would answer. Every other status (a `429` with its
711    // `Retry-After` semantics, a `5xx`) is kept.
712    *out.status_mut() = if status == StatusCode::BAD_REQUEST {
713        StatusCode::UNPROCESSABLE_ENTITY
714    } else {
715        status
716    };
717    out
718}
719
720/// A module's `detail` starts with the field it is about ("email must
721/// contain exactly one @"); match that word against the declared fields,
722/// by name or by label, case-insensitively.
723fn attribute_to_field(detail: &str, fields: &[Field]) -> Option<String> {
724    let first: String = detail
725        .chars()
726        .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
727        .collect::<String>()
728        .to_ascii_lowercase();
729    if first.is_empty() {
730        return None;
731    }
732    fields
733        .iter()
734        .find(|f| {
735            f.name.to_ascii_lowercase() == first
736                || f.label.to_ascii_lowercase() == first
737                || humanize(&f.name).to_ascii_lowercase() == first
738        })
739        .map(|f| f.name.clone())
740}
741
742/// A problem slug that names a field (`unknown-product`) attributes to
743/// it when the detail did not.
744fn attribute_by_slug(slug: &str, fields: &[Field]) -> Option<String> {
745    slug.split('-')
746        .find_map(|token| fields.iter().find(|f| f.name.eq_ignore_ascii_case(token)))
747        .map(|f| f.name.clone())
748}
749
750/// Wraps a fragment for the wire: as is with `?fragment=1`, inside the
751/// page shell with a CSP otherwise.
752///
753/// Every rendered page and fragment is `no-store` (issue #130): the same
754/// URL can carry one visitor's pre-filled values, dispatched results and
755/// landing states, and nothing an intermediary could serve to the next
756/// caller. The static assets (`cf.css`, `cf.js`, `theme.css`) are the
757/// cacheable exceptions.
758fn respond(
759    state: &UiState,
760    fragment: bool,
761    (title, body, turnstile): (String, maud::Markup, bool),
762) -> Response {
763    if fragment {
764        let mut out = Html(body.into_string()).into_response();
765        out.headers_mut()
766            .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
767        return out;
768    }
769    let theme = effective_theme_css(state);
770    let page = render::page(
771        &render::PageSpec {
772            venture: &state.ctx.venture.name,
773            title: &title,
774            theme_tokens: !state.spec.theme.tokens.is_empty(),
775            theme_css: theme.as_deref(),
776            turnstile,
777            admin: false,
778        },
779        &body,
780    );
781    let mut response = Html(page.into_string()).into_response();
782    let csp = content_security_policy(theme.as_deref(), turnstile, false);
783    if let Ok(value) = HeaderValue::from_str(&csp) {
784        response
785            .headers_mut()
786            .insert(header::CONTENT_SECURITY_POLICY, value);
787    }
788    response
789        .headers_mut()
790        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
791    response.headers_mut().insert(
792        header::REFERRER_POLICY,
793        HeaderValue::from_static("strict-origin-when-cross-origin"),
794    );
795    response.headers_mut().insert(
796        "x-content-type-options",
797        HeaderValue::from_static("nosniff"),
798    );
799    response
800}
801
802/// An admin page: the shell with the admin navigation, never cached
803/// (`private, no-store` — a session-gated response must not be stored by
804/// a shared cache even for revalidation, issue #130), never framed.
805/// `logged_in` decides whether the navigation shows.
806fn respond_admin(state: &UiState, title: &str, body: &maud::Markup, logged_in: bool) -> Response {
807    let theme = effective_theme_css(state);
808    let page = render::page(
809        &render::PageSpec {
810            venture: &state.ctx.venture.name,
811            title,
812            theme_tokens: !state.spec.theme.tokens.is_empty(),
813            theme_css: theme.as_deref(),
814            turnstile: false,
815            admin: logged_in,
816        },
817        body,
818    );
819    let mut response = Html(page.into_string()).into_response();
820    let csp = content_security_policy(theme.as_deref(), false, false);
821    if let Ok(value) = HeaderValue::from_str(&csp) {
822        response
823            .headers_mut()
824            .insert(header::CONTENT_SECURITY_POLICY, value);
825    }
826    let headers = response.headers_mut();
827    headers.insert(
828        header::CACHE_CONTROL,
829        HeaderValue::from_static("private, no-store"),
830    );
831    headers.insert(
832        header::REFERRER_POLICY,
833        HeaderValue::from_static("strict-origin-when-cross-origin"),
834    );
835    headers.insert(
836        "x-content-type-options",
837        HeaderValue::from_static("nosniff"),
838    );
839    headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
840    response
841}
842
843/// The spec's stylesheet wins over the builder's.
844fn effective_theme_css(state: &UiState) -> Option<String> {
845    state
846        .spec
847        .theme
848        .css_url
849        .clone()
850        .or_else(|| state.ui.theme_css.clone())
851}
852
853/// The `Content-Security-Policy` for a page this crate or `auth-core`
854/// renders.
855///
856/// `turnstile` opens the Turnstile origin for script, frame and connect.
857/// `inline_styles` allows `style-src 'unsafe-inline'`, which the hosted
858/// auth pages need (their brand theme is an inline `<style>` block); the
859/// `cf-*` UI pages never set it.
860#[must_use]
861pub fn content_security_policy(
862    theme_css: Option<&str>,
863    turnstile: bool,
864    inline_styles: bool,
865) -> String {
866    let mut style = String::from("'self'");
867    if inline_styles {
868        style.push_str(" 'unsafe-inline'");
869    }
870    // `cratefield_core::origin_of`, not a local split on "://" (issue
871    // #215). This decides which origins a page may load stylesheets from,
872    // so a scheme this harness would not fetch, a userinfo prefix or an
873    // unnormalised port must not reach `style-src`. A `theme_css` that is
874    // not an origin contributes nothing rather than something wrong.
875    if let Some(origin) = theme_css.and_then(|url| cratefield_core::origin_of(url).ok()) {
876        style.push(' ');
877        style.push_str(&origin);
878    }
879    let (script, frame, connect) = if turnstile {
880        (
881            render::TURNSTILE_ORIGIN.to_owned(),
882            render::TURNSTILE_ORIGIN.to_owned(),
883            format!("connect-src {}; ", render::TURNSTILE_ORIGIN),
884        )
885    } else {
886        ("'none'".to_owned(), "'none'".to_owned(), String::new())
887    };
888    format!(
889        "default-src 'none'; style-src {style}; script-src {script}; frame-src {frame}; \
890         {connect}img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
891    )
892}
893
894fn serde_urlencoded_encode(values: &BTreeMap<String, String>) -> String {
895    let mut out = String::new();
896    for (key, value) in values {
897        if !out.is_empty() {
898            out.push('&');
899        }
900        out.push_str(&percent_encode(key));
901        out.push('=');
902        out.push_str(&percent_encode(value));
903    }
904    out
905}
906
907fn percent_encode(text: &str) -> String {
908    let mut out = String::with_capacity(text.len());
909    for byte in text.bytes() {
910        match byte {
911            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
912                out.push(byte as char);
913            }
914            other => {
915                use std::fmt::Write as _;
916                let _ = write!(out, "%{other:02X}");
917            }
918        }
919    }
920    out
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn csp_opens_only_what_the_page_uses() {
929        let plain = content_security_policy(None, false, false);
930        assert!(plain.contains("script-src 'none'"));
931        assert!(plain.contains("style-src 'self';"));
932        let themed = content_security_policy(Some("https://cdn.example/theme.css"), true, false);
933        assert!(themed.contains("style-src 'self' https://cdn.example;"));
934        assert!(themed.contains("script-src https://challenges.cloudflare.com;"));
935        assert!(themed.contains("frame-src https://challenges.cloudflare.com;"));
936        let local = content_security_policy(Some("/theme.css"), false, false);
937        assert!(local.contains("style-src 'self';"));
938        // Turnstile also needs to reach its own origin for the widget's
939        // requests, and only when the widget is on the page.
940        assert!(themed.contains("connect-src https://challenges.cloudflare.com;"));
941        assert!(!plain.contains("connect-src"));
942        // Hosted pages carry their brand theme as an inline stylesheet.
943        let hosted = content_security_policy(None, true, true);
944        assert!(
945            hosted.contains("style-src 'self' 'unsafe-inline';"),
946            "{hosted}"
947        );
948        assert!(!local.contains("unsafe-inline"));
949    }
950
951    /// What the loose `origin_of` let into `style-src`, and no longer does
952    /// (issue #215). Each of these produced an entry a browser would not
953    /// match, or would match too widely.
954    #[test]
955    fn a_theme_css_that_is_not_an_origin_contributes_nothing() {
956        for bad in [
957            // Any scheme before `://` was accepted and spliced in.
958            "javascript://x/",
959            "data://x/",
960            // Not absolute at all.
961            "/theme.css",
962            "theme.css",
963            "",
964        ] {
965            let csp = content_security_policy(Some(bad), false, false);
966            assert!(
967                csp.contains("style-src 'self';"),
968                "`{bad}` must add nothing to style-src: {csp}"
969            );
970        }
971    }
972
973    /// The two that are the *same* origin and used to be two entries.
974    #[test]
975    fn an_origin_is_normalised_before_it_reaches_style_src() {
976        let default_port =
977            content_security_policy(Some("https://CDN.Example:443/a.css"), false, false);
978        assert!(
979            default_port.contains("style-src 'self' https://cdn.example;"),
980            "host lowercased and the default port dropped: {default_port}"
981        );
982        let other_port =
983            content_security_policy(Some("https://cdn.example:8443/a.css"), false, false);
984        assert!(
985            other_port.contains("style-src 'self' https://cdn.example:8443;"),
986            "a non-default port is part of the origin and stays: {other_port}"
987        );
988
989        // Userinfo is stripped rather than carried through. The loose
990        // version emitted `https://a@evil.test`, which is not an origin
991        // and which a browser therefore never matches — so the stylesheet
992        // was blocked, and for the wrong reason. The origin here is
993        // `evil.test`, and saying so is the honest answer.
994        let userinfo = content_security_policy(Some("https://a:b@evil.test/x.css"), false, false);
995        assert!(
996            userinfo.contains("style-src 'self' https://evil.test;"),
997            "the origin is the host, without the credentials: {userinfo}"
998        );
999    }
1000
1001    #[test]
1002    fn detail_attribution_matches_name_or_label() {
1003        let fields = fields_of(&serde_json::json!({
1004            "type": "object",
1005            "properties": {
1006                "email": {"type": "string", "x-cf-label": "Your email"},
1007                "product": {"type": "string"}
1008            }
1009        }));
1010        assert_eq!(
1011            attribute_to_field("email must contain exactly one @", &fields).as_deref(),
1012            Some("email")
1013        );
1014        assert_eq!(
1015            attribute_to_field("Product is not open for signups", &fields).as_deref(),
1016            Some("product")
1017        );
1018        assert_eq!(attribute_to_field("nothing here", &fields), None);
1019        assert_eq!(attribute_to_field("", &fields), None);
1020        assert_eq!(
1021            attribute_by_slug("unknown-product", &fields).as_deref(),
1022            Some("product")
1023        );
1024        assert_eq!(attribute_by_slug("captcha-failed", &fields), None);
1025    }
1026
1027    #[test]
1028    fn query_encoding_is_strict() {
1029        let values: Values = [("token".to_owned(), "a b&c=d".to_owned())].into();
1030        assert_eq!(serde_urlencoded_encode(&values), "token=a%20b%26c%3Dd");
1031    }
1032}