Skip to main content

web_net/
policy.rs

1//! Per-route cache policy, the cacheability report, and response
2//! compression.
3
4use serve_net::Outcome;
5
6use crate::RouteId;
7
8/// How a route's pages may be cached. Applied as `Cache-Control` unless the
9/// handler set one itself.
10///
11/// ```
12/// use web_net::{App, CachePolicy, get};
13///
14/// let app = App::builder()
15///     .route(get("/", |cx| cx.text(200, "home")).cache(CachePolicy::Private { max_age: 60 }))
16///     .build()?;
17/// let r = serve_net::testing::respond(&app, b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
18/// assert_eq!(r.header("cache-control"), Some(&b"private, max-age=60"[..]));
19/// # Ok::<(), web_net::BuildError>(())
20/// ```
21#[non_exhaustive]
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub enum CachePolicy {
24    /// No `Cache-Control` from web-net (the default): the handler decides.
25    #[default]
26    Unset,
27    /// `no-store`: never kept (account pages, anything personal).
28    NoStore,
29    /// `private, max-age=N`: the visitor's browser only.
30    Private {
31        /// Seconds.
32        max_age: u32,
33    },
34    /// `public, max-age=N` for everyone.
35    Public {
36        /// Seconds.
37        max_age: u32,
38    },
39    /// `public, max-age=N` for guests, `private, no-store` for signed-in
40    /// visitors: the same page, cached only while it is the same for
41    /// everyone. A cache in front of web-net may keep guests' copies
42    /// ([`Served::cache`]).
43    Guests {
44        /// Seconds.
45        max_age: u32,
46    },
47}
48
49impl CachePolicy {
50    /// The `Cache-Control` value for a visitor.
51    pub(crate) fn value(self, guest: bool) -> Option<String> {
52        match self {
53            Self::Unset => None,
54            Self::NoStore => Some("no-store".into()),
55            Self::Private { max_age } => Some(format!("private, max-age={max_age}")),
56            Self::Public { max_age } => Some(format!("public, max-age={max_age}")),
57            Self::Guests { max_age } if guest => Some(format!("public, max-age={max_age}")),
58            Self::Guests { .. } => Some("private, no-store".into()),
59        }
60    }
61}
62
63/// Whether a cache in front of web-net may keep this reply for guests.
64///
65/// ```
66/// use serve_net::{Envelope, HttpReply, HttpRequest, HttpService, Outcome};
67/// use web_net::{App, CachePolicy, Cacheable, get};
68///
69/// // A caching front that reads the report.
70/// struct Front(App);
71/// impl HttpService for Front {
72///     fn call(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
73///         let served = self.0.respond(req, env, None, reply);
74///         assert_eq!(served.cache, Cacheable::Guests { max_age: 30 });
75///         served.outcome
76///     }
77/// }
78///
79/// let app = App::builder()
80///     .route(get("/", |cx| cx.text(200, "hi")).cache(CachePolicy::Guests { max_age: 30 }))
81///     .build()?;
82/// let r = serve_net::testing::respond(&Front(app), b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
83/// assert_eq!(r.text(), "hi");
84/// # Ok::<(), web_net::BuildError>(())
85/// ```
86#[non_exhaustive]
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub enum Cacheable {
89    /// Do not keep it.
90    #[default]
91    No,
92    /// Keep it for guests' `GET` and `HEAD` of the same target, this long.
93    /// Said only when the route's policy allows it, the request was a
94    /// guest's `GET` or `HEAD`, the status is 200, and the reply sets no
95    /// cookie and varies on nothing but `Accept-Encoding`.
96    Guests {
97        /// Seconds.
98        max_age: u32,
99    },
100}
101
102/// What [`App::respond`](crate::App::respond) reports with the outcome.
103///
104/// ```
105/// use serve_net::{Envelope, HttpReply, HttpRequest, HttpService, Outcome};
106/// use web_net::{App, Cacheable, RouteId, get};
107///
108/// struct Front(App);
109/// impl HttpService for Front {
110///     fn call(&self, req: &HttpRequest<'_>, env: &Envelope, reply: &mut HttpReply) -> Outcome {
111///         let served = self.0.respond(req, env, None, reply);
112///         assert_eq!((served.route, served.cache), (Some(RouteId(0)), Cacheable::No));
113///         served.outcome
114///     }
115/// }
116///
117/// let app = App::builder().route(get("/", |cx| cx.text(200, "hi"))).build()?;
118/// let r = serve_net::testing::respond(&Front(app), b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n");
119/// assert_eq!(r.text(), "hi");
120/// # Ok::<(), web_net::BuildError>(())
121/// ```
122#[non_exhaustive]
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub struct Served {
125    /// serve-net's outcome.
126    pub outcome: Outcome,
127    /// The route that ran, if one did.
128    pub route: Option<RouteId>,
129    /// Whether a cache in front may keep the reply.
130    pub cache: Cacheable,
131}
132
133/// Response compression with gzip (compress-lib), for deployments where
134/// nothing in front compresses.
135///
136/// Compression together with secrets and reflected input in one page can
137/// leak the secrets (BREACH). csrf-sec masks its tokens per response, which
138/// is the defence for them; other secrets in compressed pages are the
139/// application's to consider.
140///
141/// ```
142/// use web_net::{App, Compression, get};
143///
144/// let app = App::builder()
145///     .compress(Compression::default().min_bytes(512).level(6))
146///     .route(get("/", |cx| cx.text(200, &"hello ".repeat(200))))
147///     .build()?;
148/// let r = serve_net::testing::respond(
149///     &app,
150///     b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n",
151/// );
152/// assert_eq!(r.header("content-encoding"), Some(&b"gzip"[..]));
153/// # Ok::<(), web_net::BuildError>(())
154/// ```
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub struct Compression {
157    pub(crate) min_bytes: usize,
158    pub(crate) level: u8,
159}
160
161impl Default for Compression {
162    fn default() -> Self {
163        Self {
164            min_bytes: 1024,
165            level: 6,
166        }
167    }
168}
169
170impl Compression {
171    /// Bodies smaller than this are sent as they are (default 1024).
172    ///
173    /// ```
174    /// use web_net::{App, Compression, get};
175    ///
176    /// // A 600-byte page is under the minimum, so it goes out as it is.
177    /// let app = App::builder()
178    ///     .compress(Compression::default().min_bytes(4096))
179    ///     .route(get("/", |cx| cx.text(200, &"hello ".repeat(100))))
180    ///     .build()?;
181    /// let r = serve_net::testing::respond(
182    ///     &app,
183    ///     b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n",
184    /// );
185    /// assert_eq!(r.header("content-encoding"), None);
186    /// # Ok::<(), web_net::BuildError>(())
187    /// ```
188    #[must_use]
189    pub fn min_bytes(mut self, bytes: usize) -> Self {
190        self.min_bytes = bytes;
191        self
192    }
193
194    /// The level, 1 (fastest) to 9 (smallest); default 6.
195    ///
196    /// ```
197    /// use web_net::{App, Compression, get};
198    ///
199    /// // Fastest; values outside 1 to 9 are clamped.
200    /// let app = App::builder()
201    ///     .compress(Compression::default().level(1))
202    ///     .route(get("/", |cx| cx.text(200, &"hello ".repeat(400))))
203    ///     .build()?;
204    /// let r = serve_net::testing::respond(
205    ///     &app,
206    ///     b"GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\n\r\n",
207    /// );
208    /// assert_eq!(r.header("content-encoding"), Some(&b"gzip"[..]));
209    /// # Ok::<(), web_net::BuildError>(())
210    /// ```
211    #[must_use]
212    pub fn level(mut self, level: u8) -> Self {
213        self.level = level.clamp(1, 9);
214        self
215    }
216}
217
218/// Media types worth compressing.
219pub(crate) fn compressible(content_type: &[u8]) -> bool {
220    let t = content_type.split(|&b| b == b';').next().unwrap_or(b"");
221    let t = t.trim_ascii();
222    t.starts_with(b"text/")
223        || matches!(
224            t,
225            b"application/json"
226                | b"application/javascript"
227                | b"application/xml"
228                | b"application/manifest+json"
229                | b"application/rss+xml"
230                | b"application/atom+xml"
231                | b"image/svg+xml"
232        )
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_policy_values() {
241        assert_eq!(CachePolicy::Unset.value(true), None);
242        assert_eq!(
243            CachePolicy::Guests { max_age: 60 }.value(true).unwrap(),
244            "public, max-age=60"
245        );
246        assert_eq!(
247            CachePolicy::Guests { max_age: 60 }.value(false).unwrap(),
248            "private, no-store"
249        );
250        assert_eq!(
251            CachePolicy::Private { max_age: 5 }.value(false).unwrap(),
252            "private, max-age=5"
253        );
254    }
255
256    #[test]
257    fn test_compressible() {
258        assert!(compressible(b"text/html; charset=utf-8"));
259        assert!(compressible(b"application/json"));
260        assert!(!compressible(b"image/png"));
261        assert!(!compressible(b""));
262    }
263}