Skip to main content

forma_server/
response.rs

1//! The page response: HTML that never leaves without its policy.
2
3use crate::csp::{CspNonce, PagePolicy};
4use axum::http::{header, HeaderName, HeaderValue, StatusCode};
5use axum::response::{IntoResponse, Response};
6use std::time::Duration;
7
8#[cfg(feature = "compression")]
9mod compression;
10#[cfg(feature = "compression")]
11pub use compression::CompressedPageResponse;
12
13/// A rendered page, ready to send.
14///
15/// As axum's [`IntoResponse`] it always writes, together with the HTML:
16///
17/// - `Cache-Control: no-store`, because the HTML embeds a nonce minted for
18///   this one response; a cached copy would hand that nonce to every later
19///   reader, and another user's page with it;
20/// - `Content-Security-Policy`, the strict policy for that nonce
21///   ([`crate::csp::build_csp_header`]) with only the handler's typed
22///   [`crate::PolicyExtensions`] added;
23/// - Trusted Types enforcement in the strict policy, in every build and without
24///   a reporting endpoint. The named route relaxation moves only those two
25///   directives to `Content-Security-Policy-Report-Only`;
26/// - Optional reporting through [`crate::PolicyExtensions::report_to`] or
27///   [`crate::PolicyExtensions::report_uri`]. Strict pages also carry a diagnostic
28///   Report-Only policy in debug builds; a relaxed route always carries it;
29/// - `Cross-Origin-Opener-Policy: same-origin` (or `same-origin-allow-popups`
30///   when the extensions ask for it), `X-Content-Type-Options`,
31///   `Referrer-Policy` and `Permissions-Policy`;
32/// - `Content-Type: text/html; charset=utf-8`.
33///
34/// A handler cannot take the HTML out and send it some other way except
35/// through [`PageResponse::into_parts_unchecked`], the one named exit, which
36/// a consumer's CI can search for:
37///
38/// ```compile_fail,E0616
39/// # fn page() -> forma_server::PageResponse { unimplemented!() }
40/// let html = page().html; // private: the HTML leaves only with its headers
41/// ```
42///
43/// ```no_run
44/// # fn page() -> forma_server::PageResponse { unimplemented!() }
45/// let parts = page().into_parts_unchecked(); // the named, searchable exit
46/// # let _ = parts.html;
47/// ```
48///
49/// Verified by: crates/forma-server/tests/internal_renderer/page_response.rs > "page_response_writes_no_store_the_policy_and_the_document_headers"
50#[derive(Debug)]
51pub struct PageResponse {
52    html: String,
53    nonce: CspNonce,
54    policy: PagePolicy,
55    counts: RenderCounts,
56    status: StatusCode,
57    #[cfg(feature = "navigation")]
58    navigation: bool,
59}
60
61/// What one render did, for the application's own logs and metrics.
62///
63/// On a [`crate::RenderError`] for a budget or a fault, the same counts say
64/// how far the walk got. For a page served without a walk (a browser
65/// shell) every count is zero. `nodes` and `list_iterations` are counted by
66/// [`crate::PageHandle::render`].
67///
68/// Walk time excludes checked props conversion and document assembly. Measure
69/// the complete handle call when deciding to move owned request data and a
70/// cloned handle into the application runtime's blocking pool.
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
72#[non_exhaustive]
73pub struct RenderCounts {
74    /// Elements and text nodes the walk wrote (hydration markers and
75    /// comments are not nodes).
76    pub nodes: usize,
77    /// Bytes of HTML the walk wrote into the page's root. State and document
78    /// markup are excluded from this work counter, but the complete response
79    /// is also checked against `WalkLimits::max_output_bytes` before allocation.
80    pub bytes: usize,
81    /// List rows the walk wrote, across every nesting depth, the quantity
82    /// `WalkLimits::max_list_iterations` bounds.
83    pub list_iterations: usize,
84    /// Time spent in the walk.
85    pub walk_time: Duration,
86}
87
88impl RenderCounts {
89    /// The walker's counts, with the time the walk took.
90    pub(crate) fn from_walk(counts: forma_ir::WalkCounts, walk_time: Duration) -> Self {
91        RenderCounts {
92            nodes: counts.nodes,
93            bytes: counts.bytes,
94            list_iterations: counts.list_iterations,
95            walk_time,
96        }
97    }
98}
99
100/// A page's raw parts, from [`PageResponse::into_parts_unchecked`].
101///
102/// Whoever sends these owns what [`PageResponse`] guaranteed: every header
103/// in `headers` goes out with `html`, and `html` is never cached or sent to a
104/// second request.
105#[derive(Debug)]
106#[non_exhaustive]
107pub struct PageParts {
108    /// The complete HTML document.
109    pub html: String,
110    /// The nonce `html` carries and the policy in `headers` blesses.
111    pub nonce: CspNonce,
112    /// Every header the response would have written, in its order.
113    pub headers: Vec<(HeaderName, HeaderValue)>,
114    /// What the render did.
115    pub counts: RenderCounts,
116    /// HTTP status that must be preserved when using this unchecked exit.
117    pub status: StatusCode,
118}
119
120impl PageResponse {
121    /// Opt this response into Brotli/gzip negotiation (the `compression` feature).
122    ///
123    /// Only call this for a route whose response cannot put a secret such as a
124    /// CSRF token beside attacker-controlled text. Compression can expose secrets
125    /// through response length (BREACH); a fresh CSP nonce and `no-store` do not
126    /// protect other secrets. This method never enables compression globally.
127    ///
128    /// Preserves every page policy header and adds `Vary: Accept-Encoding`.
129    /// Bodies below 1 KiB stay uncompressed when identity is acceptable. Axum's
130    /// GET routing supplies the same headers for HEAD and removes its body.
131    #[cfg(feature = "compression")]
132    pub fn with_compression(
133        self,
134        request_headers: &axum::http::HeaderMap,
135    ) -> CompressedPageResponse {
136        CompressedPageResponse::new(self, request_headers)
137    }
138
139    /// Wrap a rendered document whose policy was built, from the same nonce,
140    /// before the walk (so an invalid extension costs no walk).
141    pub(crate) fn with_policy(
142        html: String,
143        nonce: CspNonce,
144        policy: PagePolicy,
145        counts: RenderCounts,
146    ) -> Self {
147        PageResponse {
148            html,
149            nonce,
150            policy,
151            counts,
152            status: StatusCode::OK,
153            #[cfg(feature = "navigation")]
154            navigation: false,
155        }
156    }
157
158    #[cfg(feature = "navigation")]
159    pub(crate) fn with_navigation(mut self) -> Self {
160        self.navigation = true;
161        self
162    }
163
164    #[cfg(feature = "navigation")]
165    pub(crate) fn with_status(mut self, status: StatusCode) -> Self {
166        self.status = status;
167        self
168    }
169
170    /// What the render did: nodes, bytes and list rows walked, and time spent.
171    pub fn counts(&self) -> RenderCounts {
172        self.counts
173    }
174
175    /// The HTML, its nonce and every header, apart.
176    ///
177    /// The one way to reach the HTML without sending it as this response. It
178    /// exists for the rare handler that must assemble its own response (a
179    /// streaming body, a framework other than axum); each call site belongs
180    /// on the application's raw-entry allowlist (`forma-raw-lint`) with an
181    /// owner.
182    pub fn into_parts_unchecked(self) -> PageParts {
183        let headers = self.headers();
184        PageParts {
185            html: self.html,
186            nonce: self.nonce,
187            headers,
188            counts: self.counts,
189            status: self.status,
190        }
191    }
192
193    /// The headers this response writes, in order.
194    fn headers(&self) -> Vec<(HeaderName, HeaderValue)> {
195        let mut headers = Vec::with_capacity(9);
196        headers.push((
197            header::CONTENT_TYPE,
198            HeaderValue::from_static("text/html; charset=utf-8"),
199        ));
200        headers.push((header::CACHE_CONTROL, HeaderValue::from_static("no-store")));
201        #[cfg(feature = "navigation")]
202        if self.navigation {
203            headers.push((header::VARY, HeaderValue::from_static("Accept")));
204        }
205        headers.push((header::CONTENT_SECURITY_POLICY, self.policy.csp().clone()));
206        if let Some(report_only) = self.policy.report_only() {
207            headers.push((
208                header::CONTENT_SECURITY_POLICY_REPORT_ONLY,
209                report_only.clone(),
210            ));
211        }
212        if let Some(endpoints) = self.policy.reporting_endpoints() {
213            headers.push((
214                HeaderName::from_static("reporting-endpoints"),
215                endpoints.clone(),
216            ));
217        }
218        for (name, value) in crate::types::document_security_headers() {
219            // The opener policy is the page's own: the extensions may ask for
220            // `same-origin-allow-popups`, and a second COOP header would leave
221            // the browser to pick one.
222            if name.as_str() != "cross-origin-opener-policy" {
223                headers.push((name.clone(), value.clone()));
224            }
225        }
226        headers.push((
227            HeaderName::from_static("cross-origin-opener-policy"),
228            self.policy.opener().clone(),
229        ));
230        headers
231    }
232}
233
234impl IntoResponse for PageResponse {
235    fn into_response(self) -> Response {
236        let headers = self.headers();
237        let mut response = Response::new(axum::body::Body::from(self.html));
238        *response.status_mut() = self.status;
239        let map = response.headers_mut();
240        for (name, value) in headers {
241            map.append(name, value);
242        }
243        response
244    }
245}