larust_http/preferences.rs
1//! Long-lived, unsigned, client-writable preference cookies - backing
2//! `@globals`' `persist` entries (`persist theme = "dark"`, see
3//! `docs/MACROS.md`). Deliberately **not** the session cookie
4//! (`session.rs`): that one is scoped to the current browser session
5//! (`Expiry::OnSessionEnd` by default - no `.with_expiry(...)` call
6//! anywhere in this crate) and rotates on every login
7//! (`Session::cycle_id()`, see `larust_auth::guard::login`) - the wrong
8//! lifetime for a preference that should survive both. Also deliberately
9//! **not** signed/encrypted (`axum_extra`'s `PrivateCookieJar`/
10//! `SignedCookieJar`): tampering with your own theme preference has no
11//! security consequence, so the `Key`-management complexity those add
12//! isn't worth it here.
13//!
14//! Read-only from the server's side, by design - the write path is the
15//! browser setting `document.cookie` directly (the same "no server round
16//! trip for a purely-local UI preference" reasoning already applies to a
17//! plain client-side toggle), not a POST endpoint this module would need
18//! to expose. See `demo/resources/views/layouts/app.blade.xr`'s
19//! theme-toggle script for the real write side.
20
21pub use axum_extra::extract::cookie::CookieJar;
22
23/// Every persisted-preference cookie is named `larust_pref_{name}` - keeps
24/// this whole category of cookie visibly distinct (in devtools, in a
25/// cookie-consent audit) from the session cookie or any future unrelated
26/// app cookie, and impossible to collide with either.
27fn cookie_name(name: &str) -> String {
28 format!("larust_pref_{name}")
29}
30
31/// Reads a `persist` global's current value - `None` when the browser
32/// never set it (a first visit, or the user cleared cookies), in which
33/// case the `@globals` block's own fallback expression is what actually
34/// renders. See `larust_macros`'s `Node::PersistGlobal` codegen arm, the
35/// only caller this function is really designed for.
36pub fn get(cookies: &CookieJar, name: &str) -> Option<String> {
37 cookies
38 .get(&cookie_name(name))
39 .map(|c| c.value().to_string())
40}
41
42#[cfg(test)]
43mod tests {
44 use super::*;
45 use axum_extra::extract::cookie::Cookie;
46
47 #[test]
48 fn returns_none_when_the_cookie_is_absent() {
49 let cookies = CookieJar::new();
50 assert_eq!(get(&cookies, "theme"), None);
51 }
52
53 #[test]
54 fn returns_the_value_of_the_prefixed_cookie() {
55 let cookies = CookieJar::new().add(Cookie::new("larust_pref_theme", "light"));
56 assert_eq!(get(&cookies, "theme"), Some("light".to_string()));
57 }
58
59 #[test]
60 fn an_unrelated_cookie_of_the_same_bare_name_is_not_matched() {
61 // A plain `theme` cookie (no `larust_pref_` prefix) must never be
62 // read as the preference - the prefix is what keeps this category
63 // of cookie unambiguous, not a convention this function trusts the
64 // caller to have followed.
65 let cookies = CookieJar::new().add(Cookie::new("theme", "light"));
66 assert_eq!(get(&cookies, "theme"), None);
67 }
68}