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}