1#![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
38pub 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
45pub const CF_CSS: &str = include_str!("../assets/cf.css");
48pub const CF_JS: &str = include_str!("../assets/cf.js");
50pub const CF_SW_PUSH: &str = include_str!("../assets/sw-push.js");
58
59pub const TURNSTILE_SITE_KEY: &str = "TURNSTILE_SITE_KEY";
63
64#[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 #[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 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 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 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
199async 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
230async 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
253fn 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
277struct 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
308fn 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
318async 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
350async 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 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
398async 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
475fn 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
514fn is_admin_gated(spec: &Action) -> bool {
529 spec.audience == Audience::Admin || spec.path == "/admin" || spec.path.starts_with("/admin/")
530}
531
532async 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 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
600async 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 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 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 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 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 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 values.remove("captchaToken");
703 let mut out = respond(
704 state,
705 fragment,
706 render_form(state, spec, module, &fields, &values, &errors),
707 );
708 *out.status_mut() = if status == StatusCode::BAD_REQUEST {
713 StatusCode::UNPROCESSABLE_ENTITY
714 } else {
715 status
716 };
717 out
718}
719
720fn 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
742fn 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
750fn 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
802fn 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
843fn 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#[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 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 assert!(themed.contains("connect-src https://challenges.cloudflare.com;"));
941 assert!(!plain.contains("connect-src"));
942 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 #[test]
955 fn a_theme_css_that_is_not_an_origin_contributes_nothing() {
956 for bad in [
957 "javascript://x/",
959 "data://x/",
960 "/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 #[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 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}