Skip to main content

zeyn/
prefs.rs

1//! Client preferences: small values the browser keeps for a component, such as a consent to
2//! load embedded content or the tab a user opened last. The server never reads them, so cached
3//! public pages stay the same for everyone; preferences that change the HTML (theme, language)
4//! stay in cookies.
5//!
6//! A key is declared once as a constant with its kind, lifetime and purpose, registered on the
7//! app with [`crate::App::client_prefs`], and bound to components with `.remember(KEY)`. The
8//! browser module `zeyn.state` (`/_zeyn/state.js`) keeps each record under `zeyn.v1.{key}` as
9//! `{v, exp, val}`, checks it on every read (shape, length, expiry no further than the lifetime
10//! allows) and removes it when it does not pass. Other tabs follow through the `storage` event,
11//! and when storage is blocked the values live in memory for the page. Without script the
12//! components show their default state.
13//!
14//! ```
15//! use std::time::Duration;
16//! use zeyn::prelude::*;
17//! use zeyn::{ClientPref, Consent, Purpose};
18//!
19//! pub const VIDEO_NOTICE: ClientPref<Consent> =
20//!     ClientPref::consent("video-notice", Duration::from_secs(86_400), Purpose::Necessary);
21//!
22//! let app = App::new("Shop").client_prefs(|p| p.key(VIDEO_NOTICE));
23//! assert_eq!(app.client_prefs_table()[0].name, "video-notice");
24//! ```
25//!
26//! A component takes only a declared key of its kind; a plain string does not compile:
27//!
28//! ```compile_fail
29//! use zeyn::ui::Embed;
30//! use zeyn::Text;
31//! let video = Embed::url("https://video.example.com/embed/1")
32//!     .title(Text::from("Tour"))
33//!     .remember("video-notice");
34//! ```
35//!
36//! A choice key cannot hold a consent either:
37//!
38//! ```compile_fail
39//! use std::time::Duration;
40//! use zeyn::{ClientPref, Choice, Purpose, Text};
41//! use zeyn::ui::Embed;
42//! const TAB: ClientPref<Choice> = ClientPref::choice("tab", Duration::from_secs(60), Purpose::Preference);
43//! let video = Embed::url("https://video.example.com/embed/1").title(Text::from("Tour")).remember(TAB);
44//! ```
45//!
46//! A name that is not usable or a lifetime out of bounds fails when the constant is built:
47//!
48//! ```compile_fail
49//! use std::time::Duration;
50//! use zeyn::{ClientPref, Consent, Purpose};
51//! const BAD: ClientPref<Consent> = ClientPref::consent("no spaces", Duration::from_secs(60), Purpose::Necessary);
52//! let _ = BAD;
53//! ```
54
55use std::marker::PhantomData;
56use std::time::Duration;
57
58/// Longest lifetime of a client preference: 400 days, the longest browsers keep a cookie.
59pub const MAX_PREF_LIFETIME: Duration = Duration::from_secs(400 * 24 * 60 * 60);
60
61/// Why a preference is kept, for the privacy page and for consent.
62///
63/// The ePrivacy Directive (article 5(3)) lets a site store data in the browser without consent
64/// only when it is strictly necessary for a service the user asked for. The EU data protection
65/// authorities (Article 29 Working Party, opinion 04/2012) count a user's interface choices as
66/// necessary only for the session or a few hours; kept longer they need consent. So
67/// [`Purpose::Preference`] values are kept for the session until the app's consent hook
68/// ([`ClientPrefs::allowed_when`]) says yes, and for their whole lifetime after that.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
70#[serde(rename_all = "snake_case")]
71pub enum Purpose {
72    /// Needed for something the user asked for, such as a confirmed notice before embedded
73    /// content from another site loads. Kept for its lifetime without asking.
74    Necessary,
75    /// An interface choice, such as the tab opened last. Kept for the session unless the user
76    /// consented to preferences.
77    Preference,
78}
79
80impl Purpose {
81    pub fn code(self) -> &'static str {
82        match self {
83            Purpose::Necessary => "necessary",
84            Purpose::Preference => "preference",
85        }
86    }
87}
88
89/// The kind of a consent key: the user confirmed something, such as a notice.
90pub struct Consent;
91
92/// The kind of a choice key: a short text the user picked, such as a tab.
93pub struct Choice;
94
95/// What a preference holds, for the privacy table.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
97#[serde(rename_all = "snake_case")]
98pub enum PrefKind {
99    Consent,
100    Choice,
101}
102
103/// A declared client preference key of kind `K` ([`Consent`] or [`Choice`]). Build it as a
104/// constant; an unusable name (1 to 40 ASCII letters, digits, `-` and `_`) or a lifetime under
105/// one second or over [`MAX_PREF_LIFETIME`] does not compile.
106pub struct ClientPref<K> {
107    info: PrefInfo,
108    kind: PhantomData<fn() -> K>,
109}
110
111impl<K> Clone for ClientPref<K> {
112    fn clone(&self) -> Self {
113        *self
114    }
115}
116
117impl<K> Copy for ClientPref<K> {}
118
119/// One row of [`crate::App::client_prefs_table`]: a key as the privacy page lists it.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
121pub struct PrefInfo {
122    pub name: &'static str,
123    pub kind: PrefKind,
124    pub purpose: Purpose,
125    #[serde(serialize_with = "seconds")]
126    pub lifetime: Duration,
127}
128
129fn seconds<S: serde::Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
130    s.serialize_u64(d.as_secs())
131}
132
133const fn valid_name(name: &str) -> bool {
134    let b = name.as_bytes();
135    if b.is_empty() || b.len() > 40 {
136        return false;
137    }
138    let mut i = 0;
139    while i < b.len() {
140        if !(b[i].is_ascii_alphanumeric() || b[i] == b'-' || b[i] == b'_') {
141            return false;
142        }
143        i += 1;
144    }
145    true
146}
147
148const fn info(
149    name: &'static str,
150    kind: PrefKind,
151    lifetime: Duration,
152    purpose: Purpose,
153) -> PrefInfo {
154    assert!(
155        valid_name(name),
156        "a client preference name has 1 to 40 ASCII letters, digits, `-` or `_`, such as \"video-notice\""
157    );
158    assert!(
159        lifetime.as_secs() >= 1 && lifetime.as_secs() <= MAX_PREF_LIFETIME.as_secs(),
160        "a client preference lives 1 second to 400 days (zeyn::MAX_PREF_LIFETIME), such as Duration::from_secs(86_400)"
161    );
162    PrefInfo {
163        name,
164        kind,
165        purpose,
166        lifetime,
167    }
168}
169
170impl ClientPref<Consent> {
171    /// A consent key, such as a notice the user confirmed once.
172    pub const fn consent(name: &'static str, lifetime: Duration, purpose: Purpose) -> Self {
173        ClientPref {
174            info: info(name, PrefKind::Consent, lifetime, purpose),
175            kind: PhantomData,
176        }
177    }
178}
179
180impl ClientPref<Choice> {
181    /// A choice key, such as the tab the user opened last.
182    pub const fn choice(name: &'static str, lifetime: Duration, purpose: Purpose) -> Self {
183        ClientPref {
184            info: info(name, PrefKind::Choice, lifetime, purpose),
185            kind: PhantomData,
186        }
187    }
188}
189
190impl<K> ClientPref<K> {
191    pub fn info(&self) -> PrefInfo {
192        self.info
193    }
194
195    /// The attributes that bind an element to the key, or nothing (with a warning in the log)
196    /// when the app did not register it, so the privacy table always lists what is stored.
197    pub(crate) fn attrs(&self, r: &crate::render::Render) -> String {
198        if let Some(ctx) = r.ctx
199            && !ctx
200                .service::<ClientPrefs>()
201                .is_some_and(|p| {
202                    p.keys
203                        .iter()
204                        .any(|k| k.name == self.info.name)
205                })
206        {
207            tracing::warn!(
208                key = self.info.name,
209                "client preference key is not registered with App::client_prefs; it is not remembered"
210            );
211            return String::new();
212        }
213        format!(
214            r#" data-zeyn-remember="{}" data-zeyn-remember-for="{}" data-zeyn-remember-purpose="{}""#,
215            self.info.name,
216            self.info.lifetime.as_millis(),
217            self.info.purpose.code()
218        )
219    }
220}
221
222#[cfg(feature = "server")]
223type ConsentHook = std::sync::Arc<dyn Fn(&axum::http::HeaderMap) -> bool + Send + Sync>;
224
225/// The client preference keys of an app and its consent hook, built with
226/// [`crate::App::client_prefs`].
227#[derive(Clone, Default)]
228pub struct ClientPrefs {
229    pub(crate) keys: Vec<PrefInfo>,
230    #[cfg(feature = "server")]
231    pub(crate) allowed: Option<ConsentHook>,
232}
233
234impl crate::ctx::Service for ClientPrefs {}
235
236impl ClientPrefs {
237    /// Registers a key.
238    pub fn key<K>(mut self, key: ClientPref<K>) -> Self {
239        self.keys.push(key.info);
240        self
241    }
242
243    #[cfg(feature = "server")]
244    /// Whether the user consented to keeping [`Purpose::Preference`] values beyond the session,
245    /// read from the request (usually the cookie the app's consent banner sets). The browser asks
246    /// `GET /_zeyn/prefs` the first time it would keep such a value; the answer is never cached.
247    /// Without a hook they are kept for the session only.
248    pub fn allowed_when(
249        mut self,
250        hook: impl Fn(&axum::http::HeaderMap) -> bool + Send + Sync + 'static,
251    ) -> Self {
252        self.allowed = Some(std::sync::Arc::new(hook));
253        self
254    }
255
256    /// Names registered twice.
257    pub(crate) fn problems(&self) -> Vec<String> {
258        self.keys
259            .iter()
260            .enumerate()
261            .filter(|(i, k)| {
262                self.keys[..*i]
263                    .iter()
264                    .any(|o| o.name == k.name)
265            })
266            .map(|(_, k)| {
267                format!(
268                    "client preference `{}` is registered twice\n  = help: register each key once with App::client_prefs",
269                    k.name
270                )
271            })
272            .collect()
273    }
274}