Skip to main content

renox_core/
cookies.rs

1//! The app's own cookies: `Cookies` and `SetCookie`.
2
3use std::convert::Infallible;
4use std::time::Duration;
5
6use axum::extract::FromRequestParts;
7use axum::http::HeaderValue;
8use axum::http::header::{COOKIE, SET_COOKIE};
9use axum::http::request::Parts;
10use axum::response::{IntoResponseParts, ResponseParts};
11use cookie::{Cookie, CookieJar, Key, SameSite};
12
13use crate::AppState;
14
15/// The app's own cookies (the session has its own). Read them with the
16/// `Cookies` extractor; set them by returning a `SetCookie` with the
17/// response.
18///
19/// ```
20/// # use renox::prelude::*;
21/// use std::time::Duration;
22/// use renox::{Cookies, SetCookie};
23///
24/// async fn page(cookies: Cookies) -> View {
25///     let theme = cookies.get("theme").unwrap_or_else(|| "light".into());
26///     let referral = cookies.get_encrypted("ref"); // set with SetCookie::encrypted
27///     view("page.html", context! { theme, referral })
28/// }
29///
30/// async fn pick_theme(State(state): State<AppState>, Path(theme): Path<String>) -> (SetCookie, Redirect) {
31///     let cookie = SetCookie::new(&state, "theme", theme).max_age(Duration::from_secs(365 * 86_400));
32///     (cookie, Redirect::to("/"))
33/// }
34///
35/// async fn forget(State(state): State<AppState>) -> (SetCookie, Redirect) {
36///     (SetCookie::remove(&state, "theme"), Redirect::to("/"))
37/// }
38/// ```
39///
40/// `Cookies` holds the cookies a request came with.
41#[derive(Clone)]
42pub struct Cookies {
43    jar: CookieJar,
44    key: Key,
45}
46
47impl Cookies {
48    /// A cookie's value as the browser sent it.
49    pub fn get(&self, name: &str) -> Option<String> {
50        self.jar.get(name).map(|c| c.value().to_owned())
51    }
52
53    /// A cookie set with `SetCookie::encrypted`; `None` if it's missing or
54    /// was changed or made by anyone without `APP_KEY`.
55    pub fn get_encrypted(&self, name: &str) -> Option<String> {
56        self.jar
57            .private(&self.key)
58            .get(name)
59            .map(|c| c.value().to_owned())
60    }
61}
62
63impl FromRequestParts<AppState> for Cookies {
64    type Rejection = Infallible;
65
66    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Infallible> {
67        let mut jar = CookieJar::new();
68        for header in parts.headers.get_all(COOKIE) {
69            let Ok(header) = header.to_str() else {
70                continue;
71            };
72            for cookie in Cookie::split_parse_encoded(header.to_owned()).flatten() {
73                jar.add_original(cookie);
74            }
75        }
76        Ok(Self {
77            jar,
78            key: state.key.clone(),
79        })
80    }
81}
82
83/// A cookie to set, returned with the response: `(SetCookie::new(…), view)`.
84/// By default it's for the whole site (`Path=/`), not readable by
85/// JavaScript (`HttpOnly`), `SameSite=Lax`, `Secure` when `APP_URL` is
86/// https, and lasts until the browser closes (see `max_age`).
87#[derive(Debug, Clone)]
88pub struct SetCookie(Cookie<'static>);
89
90impl SetCookie {
91    /// A cookie `name` with `value` and the defaults above.
92    pub fn new(state: &AppState, name: impl Into<String>, value: impl Into<String>) -> Self {
93        let mut cookie = Cookie::new(name.into(), value.into());
94        cookie.set_path("/");
95        cookie.set_http_only(true);
96        cookie.set_same_site(SameSite::Lax);
97        cookie.set_secure(state.config.url.starts_with("https://"));
98        Self(cookie)
99    }
100
101    /// Encrypted and signed with `APP_KEY`: the browser can't read or change
102    /// it. Read it back with `Cookies::get_encrypted`.
103    pub fn encrypted(state: &AppState, name: impl Into<String>, value: impl Into<String>) -> Self {
104        let plain = Self::new(state, name, value).0;
105        let mut jar = CookieJar::new();
106        jar.private_mut(&state.key).add(plain.clone());
107        let sealed = jar.get(plain.name()).cloned().unwrap_or(plain);
108        Self(sealed)
109    }
110
111    /// Deletes the cookie `name` in the browser.
112    pub fn remove(state: &AppState, name: impl Into<String>) -> Self {
113        let mut cookie = Self::new(state, name, "").0;
114        cookie.make_removal();
115        Self(cookie)
116    }
117
118    /// How long the cookie lasts.
119    pub fn max_age(mut self, age: Duration) -> Self {
120        let seconds = i64::try_from(age.as_secs()).unwrap_or(i64::MAX);
121        self.0.set_max_age(cookie::time::Duration::seconds(seconds));
122        self
123    }
124
125    /// Limits the cookie to URLs under `path` (default `/`).
126    pub fn path(mut self, path: impl Into<String>) -> Self {
127        self.0.set_path(path.into());
128        self
129    }
130
131    /// Lets JavaScript read it (`HttpOnly` off), e.g. a UI preference that
132    /// a script applies.
133    pub fn readable_by_scripts(mut self) -> Self {
134        self.0.set_http_only(false);
135        self
136    }
137
138    /// `SameSite=Strict`: not sent when arriving from another site.
139    pub fn strict(mut self) -> Self {
140        self.0.set_same_site(SameSite::Strict);
141        self
142    }
143}
144
145impl IntoResponseParts for SetCookie {
146    type Error = Infallible;
147
148    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Infallible> {
149        if let Ok(value) = HeaderValue::from_str(&self.0.encoded().to_string()) {
150            res.headers_mut().append(SET_COOKIE, value);
151        }
152        Ok(res)
153    }
154}