ajout de l'option tracking generique

EBO <eric@ebii.fr> committé le 2026-09-24 10:57

a50d8d10330c85cdfb7dabfcd5b1b10752056464

1 parent(s)

23 fichiers modifiés +2540 -2
M .gitignore
+1 -0
@@ -20,6 +20,7 @@
20 20 .claude/
21 21 .omc/
22 22 CLAUDE.md
23 +.serena/
23 24
24 25 # Uncomment to share workspace settings: !.vscode/settings.json
25 26
M CHANGELOG.md
+32 -0
@@ -5,6 +5,38 @@ All notable changes to this project are documented in this file.
5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 7
8 +## [Unreleased]
9 +
10 +### Added
11 +
12 +- **Mesure d'audience générique** (`config::analytics`). Support d'un script de suivi
13 + tiers modélisé comme une URL plus des attributs `data-*` : couvre Rybbit, Plausible,
14 + Umami et Matomo-tag sans code spécifique par fournisseur. Ajout additif — par défaut
15 + le suivi est **désactivé**, rien n'est rendu et la CSP reste bit-identique.
16 + - `AnalyticsConfig` (prelude) : `from_env`, `validate`, `script_origin`,
17 + `render_snippet`, setters chaînables.
18 + - `AnalyticsState` / `AnalyticsContext` / `AnalyticsHandle` (prelude) : snippet
19 + pré-rendu au démarrage et rechargeable à chaud, plus deux extracteurs Axum
20 + **infaillibles** pour les templates et l'écran d'administration.
21 + - `RustwardenBuilder::analytics(cfg)`.
22 + - `SecurityHeadersConfig::allowing_script_origin(origin)` : ajoute une origine à
23 + `script-src` **et** `connect-src`. Appliqué par `build()`, donc l'ordre des appels
24 + `.analytics(..)` / `.security_headers(..)` est indifférent.
25 + - `rustwarden-ui` : section « Mesure d'audience » dans `/admin/settings`, composant
26 + d'opposition `components/analytics_opt_out.html` (clé `localStorage` configurable),
27 + traductions `analytics.opt_out.*` et `admin.settings.analytics_*` dans les six locales.
28 + - Trois clés `app_settings` : `analytics_enabled`, `analytics_site_id`,
29 + `analytics_attributes`.
30 +
31 + **Aucune migration SeaORM.** Aucun changement de comportement pour les consommateurs
32 + existants.
33 +
34 + ⚠️ **L'URL du script n'est pas modifiable depuis l'administration** : elle alimente la
35 + CSP, capturée une fois au `build()` (contrainte RFC 0039), et reste donc une variable
36 + d'environnement (`ANALYTICS_SCRIPT_URL`). C'est aussi ce qui empêche un compte
37 + d'administration compromis d'élargir la CSP vers un domaine arbitraire. Détail :
38 + [`docs/SERVICES_REFERENCE.md`](docs/SERVICES_REFERENCE.md) §23.
39 +
8 40 ## [1.1.0] - 2026-09-18 — Security
9 41
10 42 Minor de sécurité livrant les **RFC 0036 à 0040**, toutes passées **Accepted le
M crates/rustwarden-core/src/app.rs
+152 -1
@@ -33,6 +33,7 @@ use tower_http::cors::{AllowOrigin, Any, CorsLayer};
33 33
34 34 use crate::auth::session::SessionConfig;
35 35 use crate::config::auth::AuthConfig;
36 +use crate::config::analytics::{AnalyticsConfig, AnalyticsState};
36 37 use crate::config::database;
37 38 use crate::config::email::EmailConfig;
38 39 use crate::config::security_headers::{DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig};
@@ -184,6 +185,10 @@ pub struct RustwardenBuilder {
184 185 /// (RFC 0039). Defaults to the 1.0.1-compatible policy: legacy CSP,
185 186 /// no COOP/CORP. Overridden via [`RustwardenBuilder::security_headers`].
186 187 security_headers: SecurityHeadersConfig,
188 + /// Audience-measurement configuration. Defaults to disabled, which
189 + /// renders nothing and leaves the CSP untouched. Set via
190 + /// [`RustwardenBuilder::analytics`].
191 + analytics: AnalyticsConfig,
187 192 }
188 193
189 194 impl Default for RustwardenBuilder {
@@ -212,6 +217,7 @@ impl Default for RustwardenBuilder {
212 217 auth_failure_mode: None,
213 218 headless: false,
214 219 security_headers: SecurityHeadersConfig::default(),
220 + analytics: AnalyticsConfig::default(),
215 221 }
216 222 }
217 223 }
@@ -263,6 +269,41 @@ impl RustwardenBuilder {
263 269 self
264 270 }
265 271
272 + /// Configure audience measurement (analytics).
273 + ///
274 + /// Default: [`AnalyticsConfig::default`] — disabled, rendering nothing
275 + /// and leaving the CSP untouched.
276 + ///
277 + /// When a script URL is configured, [`build`](Self::build) allows its
278 + /// origin in the `script-src` and `connect-src` CSP directives. That
279 + /// happens **at build time, not here**, so the order of the
280 + /// `.analytics(..)` and `.security_headers(..)` calls does not matter: a
281 + /// later `.security_headers(..)` cannot silently drop the allowance.
282 + ///
283 + /// The runtime toggle, site identifier and extra attributes can be
284 + /// overridden from the `app_settings` table; the script URL cannot, since
285 + /// the CSP is frozen once the router is built. See
286 + /// [`crate::config::analytics`].
287 + ///
288 + /// # Examples
289 + ///
290 + /// ```no_run
291 + /// use rustwarden_core::app::RustwardenApp;
292 + /// use rustwarden_core::config::analytics::AnalyticsConfig;
293 + ///
294 + /// let builder = RustwardenApp::builder().analytics(
295 + /// AnalyticsConfig::default()
296 + /// .with_script_url("https://stats.example.org/api/script.js")
297 + /// .with_site_id("abc123")
298 + /// .with_opt_out_storage_key("disable-rybbit")
299 + /// .enabled(true),
300 + /// );
301 + /// ```
302 + pub fn analytics(mut self, cfg: AnalyticsConfig) -> Self {
303 + self.analytics = cfg;
304 + self
305 + }
306 +
266 307 /// Merge additional custom routes into the **browser** family.
267 308 ///
268 309 /// Browser routes are session-cookie driven and therefore receive the
@@ -719,7 +760,29 @@ impl RustwardenBuilder {
719 760 // extensions are therefore never visible to it on the way in, so the
720 761 // config is captured in the closure as an `Arc` (same pattern as
721 762 // `locale_middleware`), NOT injected via `Extension`.
722 - let headers_cfg = std::sync::Arc::new(self.security_headers.clone());
763 + // The analytics origin is folded into the CSP *here* rather than in
764 + // `RustwardenBuilder::analytics`, so that a later `.security_headers(..)`
765 + // call cannot silently drop the allowance. A disabled configuration
766 + // with no script URL leaves the policy byte-for-byte untouched.
767 + let mut security_headers = self.security_headers.clone();
768 + if let Some(origin) = self.analytics.script_origin() {
769 + security_headers = security_headers.allowing_script_origin(&origin);
770 + }
771 + let headers_cfg = std::sync::Arc::new(security_headers);
772 +
773 + // Resolve the `app_settings` overrides once, at startup. Failing to
774 + // read them must not prevent the application from booting: fall back
775 + // to the environment configuration.
776 + let analytics_state = match AnalyticsState::load(&db, self.analytics.clone()).await {
777 + Ok(state) => std::sync::Arc::new(state),
778 + Err(e) => {
779 + tracing::warn!(
780 + error = %e,
781 + "could not read analytics settings; using the environment configuration"
782 + );
783 + std::sync::Arc::new(AnalyticsState::from_config(self.analytics.clone()))
784 + }
785 + };
723 786
724 787 let app = router.with_state(db).layer(
725 788 ServiceBuilder::new()
@@ -745,6 +808,7 @@ impl RustwardenBuilder {
745 808 .layer(axum::Extension(hooks))
746 809 .layer(axum::Extension(auth_config))
747 810 .layer(axum::Extension(db_for_ext))
811 + .layer(axum::Extension(analytics_state))
748 812 .into_inner(),
749 813 );
750 814
@@ -1234,4 +1298,91 @@ mod security_headers_middleware_tests {
1234 1298 "camera=(), microphone=(), geolocation=()"
1235 1299 );
1236 1300 }
1301 +
1302 + // --- Analytics origin folded into the CSP -------------------------------
1303 +
1304 + /// Mirror of what `RustwardenBuilder::build` does with the analytics
1305 + /// configuration, so these tests exercise the real composition rule.
1306 + fn headers_for(analytics: &AnalyticsConfig, base: SecurityHeadersConfig) -> String {
1307 + let cfg = match analytics.script_origin() {
1308 + Some(origin) => base.allowing_script_origin(&origin),
1309 + None => base,
1310 + };
1311 + cfg.content_security_policy.to_string()
1312 + }
1313 +
1314 + #[tokio::test]
1315 + async fn analytics_disabled_leaves_the_csp_byte_identical() {
1316 + // The whole point of the default: a consumer that never configures
1317 + // analytics must see the historical header, unchanged.
1318 + let csp = headers_for(
1319 + &AnalyticsConfig::default(),
1320 + SecurityHeadersConfig::default(),
1321 + );
1322 + let cfg = SecurityHeadersConfig {
1323 + content_security_policy: Cow::Owned(csp),
1324 + ..SecurityHeadersConfig::default()
1325 + };
1326 + let resp = get_root(test_router(cfg)).await;
1327 + assert_eq!(
1328 + resp.headers()
1329 + .get("content-security-policy")
1330 + .expect("csp header present"),
1331 + DEFAULT_CONTENT_SECURITY_POLICY
1332 + );
1333 + }
1334 +
1335 + #[tokio::test]
1336 + async fn analytics_origin_reaches_the_emitted_header() {
1337 + let analytics = AnalyticsConfig::default()
1338 + .with_script_url("https://stats.example.org/api/script.js")
1339 + .with_site_id("abc123")
1340 + .enabled(true);
1341 + let csp = headers_for(&analytics, SecurityHeadersConfig::default());
1342 + let cfg = SecurityHeadersConfig {
1343 + content_security_policy: Cow::Owned(csp),
1344 + ..SecurityHeadersConfig::default()
1345 + };
1346 +
1347 + let resp = get_root(test_router(cfg)).await;
1348 + let header = resp
1349 + .headers()
1350 + .get("content-security-policy")
1351 + .and_then(|v| v.to_str().ok())
1352 + .unwrap_or_default()
1353 + .to_string();
1354 +
1355 + let directive = |name: &str| -> String {
1356 + header
1357 + .split(';')
1358 + .map(str::trim)
1359 + .find(|d| d.split_whitespace().next() == Some(name))
1360 + .unwrap_or_default()
1361 + .to_string()
1362 + };
1363 +
1364 + assert!(
1365 + directive("script-src").contains("https://stats.example.org"),
1366 + "script-src must allow the tracker: {}",
1367 + header
1368 + );
1369 + assert!(
1370 + directive("connect-src").contains("https://stats.example.org"),
1371 + "connect-src must allow the beacons: {}",
1372 + header
1373 + );
1374 + // Only the path is dropped — the origin keeps scheme and host.
1375 + assert!(!header.contains("/api/script.js"));
1376 + }
1377 +
1378 + #[tokio::test]
1379 + async fn analytics_origin_is_allowed_even_while_tracking_is_off() {
1380 + // The runtime toggle lives in the database and may be flipped on
1381 + // without a restart, so the CSP must already allow the origin.
1382 + let analytics = AnalyticsConfig::default()
1383 + .with_script_url("https://stats.example.org/api/script.js")
1384 + .enabled(false);
1385 + let csp = headers_for(&analytics, SecurityHeadersConfig::default());
1386 + assert!(csp.contains("https://stats.example.org"));
1387 + }
1237 1388 }
A crates/rustwarden-core/src/config/analytics.rs
+997 -0
@@ -0,0 +1,997 @@
1 +//! Audience-measurement (analytics) configuration.
2 +//!
3 +//! Provider-agnostic support for a third-party tracking script, modelled as a
4 +//! script URL plus a set of `data-*` attributes. That shape covers Rybbit,
5 +//! Plausible, Umami, Matomo-tag and similar vendors without shipping any
6 +//! vendor-specific code: only the attribute names differ between them.
7 +//!
8 +//! # Split between environment and database
9 +//!
10 +//! The **script URL** comes from the environment or the builder and is frozen
11 +//! at startup, because it feeds the `Content-Security-Policy` emitted by
12 +//! `security_headers_middleware`, which captures its configuration once when
13 +//! the router is built (see `RustwardenBuilder::build`). A URL editable at
14 +//! runtime would leave the CSP out of sync until the next restart.
15 +//!
16 +//! Everything else — whether tracking is on, the site identifier, the extra
17 +//! attributes — may be overridden from the `app_settings` table and takes
18 +//! effect without a restart, through [`AnalyticsState::reload`].
19 +//!
20 +//! Keeping the origin out of the database is also a security property: a
21 +//! compromised administrator account cannot widen the CSP towards an
22 +//! arbitrary domain, which would be an exfiltration primitive.
23 +//!
24 +//! # Example
25 +//!
26 +//! ```
27 +//! use rustwarden_core::config::analytics::AnalyticsConfig;
28 +//!
29 +//! let cfg = AnalyticsConfig::default()
30 +//! .with_script_url("https://stats.example.org/api/script.js")
31 +//! .with_site_id("abc123")
32 +//! .enabled(true);
33 +//!
34 +//! assert!(cfg.validate().is_ok());
35 +//! assert_eq!(cfg.script_origin().as_deref(), Some("https://stats.example.org"));
36 +//!
37 +//! let snippet = cfg.render_snippet().expect("enabled config renders");
38 +//! assert!(snippet.contains(r#"data-site-id="abc123""#));
39 +//! ```
40 +
41 +use crate::error::AppError;
42 +use crate::services::app_settings_service::AppSettingsService;
43 +use sea_orm::DatabaseConnection;
44 +use std::collections::BTreeMap;
45 +use std::sync::{Arc, RwLock};
46 +
47 +/// Attribute carrying the site identifier for most vendors (Rybbit, Umami
48 +/// uses `data-website-id`, Plausible uses `data-domain`).
49 +pub const DEFAULT_SITE_ID_ATTRIBUTE: &str = "data-site-id";
50 +
51 +/// `app_settings` key toggling audience measurement at runtime.
52 +pub const SETTING_ANALYTICS_ENABLED: &str = "analytics_enabled";
53 +/// `app_settings` key holding the site identifier at runtime.
54 +pub const SETTING_ANALYTICS_SITE_ID: &str = "analytics_site_id";
55 +/// `app_settings` key holding extra `data-*` attributes, as a JSON object.
56 +pub const SETTING_ANALYTICS_ATTRIBUTES: &str = "analytics_attributes";
57 +
58 +/// Reasons an [`AnalyticsConfig`] can be rejected.
59 +///
60 +/// Validation is the primary security gate of this module: values reaching it
61 +/// may originate from the administration panel, and end up inside rendered
62 +/// HTML.
63 +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
64 +pub enum AnalyticsConfigError {
65 + /// Tracking is enabled but no script URL was supplied.
66 + #[error("analytics is enabled but no script URL is configured")]
67 + MissingScriptUrl,
68 + /// The script URL is not an absolute `https://` URL (or `http://` on a
69 + /// loopback host).
70 + #[error("invalid analytics script URL: {0}")]
71 + InvalidScriptUrl(String),
72 + /// An attribute name is not a plain `data-*` name.
73 + #[error("invalid analytics attribute name `{0}`: expected `data-` followed by [a-z0-9-]")]
74 + InvalidAttributeName(String),
75 + /// An attribute value, or the site identifier, contains a character
76 + /// outside the allow-list.
77 + #[error("invalid value for analytics attribute `{name}`: {reason}")]
78 + InvalidAttributeValue {
79 + /// Attribute the offending value belongs to.
80 + name: String,
81 + /// Why the value was rejected.
82 + reason: String,
83 + },
84 + /// The same attribute is declared twice (site-id attribute and extras).
85 + #[error("analytics attribute `{0}` is declared both as site-id attribute and as an extra")]
86 + DuplicateAttribute(String),
87 + /// The opt-out localStorage key contains unsupported characters.
88 + #[error("invalid opt-out storage key `{0}`: expected [A-Za-z0-9._-]")]
89 + InvalidOptOutKey(String),
90 + /// The JSON payload of extra attributes could not be parsed.
91 + #[error("invalid analytics attributes JSON: {0}")]
92 + InvalidAttributesJson(String),
93 +}
94 +
95 +/// Generic audience-measurement configuration.
96 +///
97 +/// [`Default`] is deliberately conservative: tracking is **off** and nothing
98 +/// is rendered, so a consumer that never configures analytics observes no
99 +/// change whatsoever — neither in the page nor in the CSP.
100 +#[derive(Clone, Debug, PartialEq, Eq)]
101 +pub struct AnalyticsConfig {
102 + /// Whether the tracking script is rendered. Default: `false`.
103 + pub enabled: bool,
104 + /// Absolute URL of the tracking script. Feeds the CSP, therefore only
105 + /// settable from the environment or the builder.
106 + pub script_url: Option<String>,
107 + /// Site identifier handed to the vendor.
108 + pub site_id: Option<String>,
109 + /// Attribute carrying [`Self::site_id`]. Default:
110 + /// [`DEFAULT_SITE_ID_ATTRIBUTE`].
111 + pub site_id_attribute: String,
112 + /// Extra `data-*` attributes, e.g. `data-consent` → `opt-in`. Ordered, so
113 + /// the rendered snippet is deterministic.
114 + pub attributes: BTreeMap<String, String>,
115 + /// `localStorage` key a visitor can set to opt out. Vendor-specific, e.g.
116 + /// `disable-rybbit`. `None` disables the opt-out component.
117 + pub opt_out_storage_key: Option<String>,
118 + /// Whether to render the `defer` attribute. Default: `true`.
119 + pub defer: bool,
120 +}
121 +
122 +impl Default for AnalyticsConfig {
123 + fn default() -> Self {
124 + Self {
125 + enabled: false,
126 + script_url: None,
127 + site_id: None,
128 + site_id_attribute: DEFAULT_SITE_ID_ATTRIBUTE.to_string(),
129 + attributes: BTreeMap::new(),
130 + opt_out_storage_key: None,
131 + defer: true,
132 + }
133 + }
134 +}
135 +
136 +impl AnalyticsConfig {
137 + /// Load the configuration from the environment.
138 + ///
139 + /// Reads `ANALYTICS_ENABLED`, `ANALYTICS_SCRIPT_URL`, `ANALYTICS_SITE_ID`,
140 + /// `ANALYTICS_SITE_ID_ATTRIBUTE`, `ANALYTICS_ATTRIBUTES` (a JSON object),
141 + /// `ANALYTICS_OPT_OUT_STORAGE_KEY` and `ANALYTICS_DEFER`.
142 + ///
143 + /// With none of them set, returns [`Default`] — tracking disabled.
144 + ///
145 + /// # Errors
146 + ///
147 + /// Returns [`AnalyticsConfigError`] if the resulting configuration does
148 + /// not pass [`Self::validate`], or if `ANALYTICS_ATTRIBUTES` is not a
149 + /// JSON object of strings.
150 + pub fn from_env() -> Result<Self, AnalyticsConfigError> {
151 + let mut cfg = Self::default();
152 +
153 + if let Ok(raw) = std::env::var("ANALYTICS_ENABLED") {
154 + cfg.enabled = matches!(raw.trim().to_ascii_lowercase().as_str(), "true" | "1" | "on");
155 + }
156 + if let Ok(url) = std::env::var("ANALYTICS_SCRIPT_URL") {
157 + let url = url.trim().to_string();
158 + if !url.is_empty() {
159 + cfg.script_url = Some(url);
160 + }
161 + }
162 + if let Ok(site_id) = std::env::var("ANALYTICS_SITE_ID") {
163 + let site_id = site_id.trim().to_string();
164 + if !site_id.is_empty() {
165 + cfg.site_id = Some(site_id);
166 + }
167 + }
168 + if let Ok(attr) = std::env::var("ANALYTICS_SITE_ID_ATTRIBUTE") {
169 + let attr = attr.trim().to_string();
170 + if !attr.is_empty() {
171 + cfg.site_id_attribute = attr;
172 + }
173 + }
174 + if let Ok(raw) = std::env::var("ANALYTICS_ATTRIBUTES") {
175 + if !raw.trim().is_empty() {
176 + cfg.attributes = parse_attributes_json(&raw)?;
177 + }
178 + }
179 + if let Ok(key) = std::env::var("ANALYTICS_OPT_OUT_STORAGE_KEY") {
180 + let key = key.trim().to_string();
181 + if !key.is_empty() {
182 + cfg.opt_out_storage_key = Some(key);
183 + }
184 + }
185 + if let Ok(raw) = std::env::var("ANALYTICS_DEFER") {
186 + cfg.defer = !matches!(raw.trim().to_ascii_lowercase().as_str(), "false" | "0" | "off");
187 + }
188 +
189 + cfg.validate()?;
190 + Ok(cfg)
191 + }
192 +
193 + /// Builder-style setter for [`Self::script_url`].
194 + pub fn with_script_url(mut self, url: impl Into<String>) -> Self {
195 + self.script_url = Some(url.into());
196 + self
197 + }
198 +
199 + /// Builder-style setter for [`Self::site_id`].
200 + pub fn with_site_id(mut self, site_id: impl Into<String>) -> Self {
201 + self.site_id = Some(site_id.into());
202 + self
203 + }
204 +
205 + /// Builder-style setter for [`Self::site_id_attribute`], e.g.
206 + /// `data-domain` for Plausible or `data-website-id` for Umami.
207 + pub fn with_site_id_attribute(mut self, attribute: impl Into<String>) -> Self {
208 + self.site_id_attribute = attribute.into();
209 + self
210 + }
211 +
212 + /// Add one extra `data-*` attribute.
213 + pub fn with_attribute(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
214 + self.attributes.insert(name.into(), value.into());
215 + self
216 + }
217 +
218 + /// Replace the extra attributes from a JSON object of string values.
219 + ///
220 + /// This is the format stored in `app_settings` and submitted by the
221 + /// administration form.
222 + ///
223 + /// # Errors
224 + ///
225 + /// Returns [`AnalyticsConfigError::InvalidAttributesJson`] if the payload
226 + /// is not a JSON object whose values are all strings.
227 + pub fn with_attributes_json(mut self, raw: &str) -> Result<Self, AnalyticsConfigError> {
228 + self.attributes = parse_attributes_json(raw)?;
229 + Ok(self)
230 + }
231 +
232 + /// Builder-style setter for [`Self::opt_out_storage_key`].
233 + pub fn with_opt_out_storage_key(mut self, key: impl Into<String>) -> Self {
234 + self.opt_out_storage_key = Some(key.into());
235 + self
236 + }
237 +
238 + /// Builder-style setter for [`Self::enabled`].
239 + pub fn enabled(mut self, enabled: bool) -> Self {
240 + self.enabled = enabled;
241 + self
242 + }
243 +
244 + /// Validate the configuration.
245 + ///
246 + /// A disabled configuration with no script URL is always valid: that is
247 + /// the default, and it renders nothing.
248 + ///
249 + /// # Errors
250 + ///
251 + /// See [`AnalyticsConfigError`].
252 + pub fn validate(&self) -> Result<(), AnalyticsConfigError> {
253 + if let Some(url) = &self.script_url {
254 + validate_script_url(url)?;
255 + } else if self.enabled {
256 + return Err(AnalyticsConfigError::MissingScriptUrl);
257 + }
258 +
259 + validate_attribute_name(&self.site_id_attribute)?;
260 + if let Some(site_id) = &self.site_id {
261 + validate_attribute_value(&self.site_id_attribute, site_id)?;
262 + }
263 +
264 + for (name, value) in &self.attributes {
265 + if name == &self.site_id_attribute {
266 + return Err(AnalyticsConfigError::DuplicateAttribute(name.clone()));
267 + }
268 + validate_attribute_name(name)?;
269 + validate_attribute_value(name, value)?;
270 + }
271 +
272 + if let Some(key) = &self.opt_out_storage_key {
273 + if key.is_empty() || !key.chars().all(is_storage_key_char) {
274 + return Err(AnalyticsConfigError::InvalidOptOutKey(key.clone()));
275 + }
276 + }
277 +
278 + Ok(())
279 + }
280 +
281 + /// Origin (`scheme://host[:port]`) of the tracking script, for the CSP.
282 + ///
283 + /// Returns `None` when no script URL is configured, whether or not
284 + /// tracking is enabled — the origin is what the CSP must allow, and it is
285 + /// frozen at startup regardless of the runtime toggle.
286 + pub fn script_origin(&self) -> Option<String> {
287 + self.script_url.as_deref().and_then(script_origin_of)
288 + }
289 +
290 + /// Render the `<script>` tag, or `None` when tracking is disabled or no
291 + /// script URL is configured.
292 + ///
293 + /// Every interpolated value is HTML-escaped, on top of the allow-list
294 + /// enforced by [`Self::validate`]. The returned string is therefore safe
295 + /// to emit through Askama's `|safe` filter.
296 + pub fn render_snippet(&self) -> Option<String> {
297 + if !self.enabled {
298 + return None;
299 + }
300 + let url = self.script_url.as_deref()?;
301 +
302 + let mut tag = String::from("<script src=\"");
303 + tag.push_str(&escape_html_attribute(url));
304 + tag.push('"');
305 +
306 + if let Some(site_id) = &self.site_id {
307 + tag.push(' ');
308 + tag.push_str(&escape_html_attribute(&self.site_id_attribute));
309 + tag.push_str("=\"");
310 + tag.push_str(&escape_html_attribute(site_id));
311 + tag.push('"');
312 + }
313 +
314 + for (name, value) in &self.attributes {
315 + tag.push(' ');
316 + tag.push_str(&escape_html_attribute(name));
317 + tag.push_str("=\"");
318 + tag.push_str(&escape_html_attribute(value));
319 + tag.push('"');
320 + }
321 +
322 + if self.defer {
323 + tag.push_str(" defer");
324 + }
325 + tag.push_str("></script>");
326 + Some(tag)
327 + }
328 +}
329 +
330 +/// Parse a JSON object of string values into an attribute map.
331 +fn parse_attributes_json(raw: &str) -> Result<BTreeMap<String, String>, AnalyticsConfigError> {
332 + let parsed: serde_json::Value = serde_json::from_str(raw)
333 + .map_err(|e| AnalyticsConfigError::InvalidAttributesJson(e.to_string()))?;
334 + let object = parsed.as_object().ok_or_else(|| {
335 + AnalyticsConfigError::InvalidAttributesJson("expected a JSON object".to_string())
336 + })?;
337 +
338 + let mut attributes = BTreeMap::new();
339 + for (name, value) in object {
340 + let value = value.as_str().ok_or_else(|| {
341 + AnalyticsConfigError::InvalidAttributesJson(format!(
342 + "value of `{}` is not a string",
343 + name
344 + ))
345 + })?;
346 + attributes.insert(name.clone(), value.to_string());
347 + }
348 + Ok(attributes)
349 +}
350 +
351 +/// Characters accepted in attribute values and site identifiers. Wide enough
352 +/// for Plausible's comma-separated `data-domain`, narrow enough to exclude
353 +/// every HTML metacharacter.
354 +fn is_attribute_value_char(c: char) -> bool {
355 + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | ',')
356 +}
357 +
358 +/// Characters accepted in a `localStorage` key.
359 +fn is_storage_key_char(c: char) -> bool {
360 + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
361 +}
362 +
363 +/// Reject anything that is not a plain `data-*` attribute name.
364 +///
365 +/// This is what keeps `onload`, `src` and `integrity` out of the rendered
366 +/// tag: an administrator can add attributes, never behaviour.
367 +fn validate_attribute_name(name: &str) -> Result<(), AnalyticsConfigError> {
368 + let Some(suffix) = name.strip_prefix("data-") else {
369 + return Err(AnalyticsConfigError::InvalidAttributeName(name.to_string()));
370 + };
371 + let valid = !suffix.is_empty()
372 + && suffix
373 + .chars()
374 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
375 + if valid {
376 + Ok(())
377 + } else {
378 + Err(AnalyticsConfigError::InvalidAttributeName(name.to_string()))
379 + }
380 +}
381 +
382 +fn validate_attribute_value(name: &str, value: &str) -> Result<(), AnalyticsConfigError> {
383 + if value.is_empty() {
384 + return Err(AnalyticsConfigError::InvalidAttributeValue {
385 + name: name.to_string(),
386 + reason: "value is empty".to_string(),
387 + });
388 + }
389 + if let Some(bad) = value.chars().find(|c| !is_attribute_value_char(*c)) {
390 + return Err(AnalyticsConfigError::InvalidAttributeValue {
391 + name: name.to_string(),
392 + reason: format!("unsupported character `{}`", bad),
393 + });
394 + }
395 + Ok(())
396 +}
397 +
398 +/// Accept absolute `https://` URLs, plus `http://` on a loopback host so the
399 +/// feature can be exercised against a local collector in development.
400 +fn validate_script_url(url: &str) -> Result<(), AnalyticsConfigError> {
401 + if url.chars().any(|c| c.is_control() || c.is_whitespace()) {
402 + return Err(AnalyticsConfigError::InvalidScriptUrl(
403 + "URL contains whitespace or control characters".to_string(),
404 + ));
405 + }
406 + if url.contains("..") {
407 + return Err(AnalyticsConfigError::InvalidScriptUrl(
408 + "URL contains a path traversal sequence".to_string(),
409 + ));
410 + }
411 +
412 + let (scheme, rest) = url.split_once("://").ok_or_else(|| {
413 + AnalyticsConfigError::InvalidScriptUrl("URL must be absolute".to_string())
414 + })?;
415 +
416 + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
417 + if authority.is_empty() {
418 + return Err(AnalyticsConfigError::InvalidScriptUrl(
419 + "URL has no host".to_string(),
420 + ));
421 + }
422 + if authority.contains('@') {
423 + return Err(AnalyticsConfigError::InvalidScriptUrl(
424 + "URL must not carry credentials".to_string(),
425 + ));
426 + }
427 +
428 + let host = host_of_authority(authority);
429 + match scheme {
430 + "https" => Ok(()),
431 + "http" if is_loopback_host(host) => Ok(()),
432 + "http" => Err(AnalyticsConfigError::InvalidScriptUrl(
433 + "plain http is only allowed on a loopback host".to_string(),
434 + )),
435 + other => Err(AnalyticsConfigError::InvalidScriptUrl(format!(
436 + "unsupported scheme `{}`",
437 + other
438 + ))),
439 + }
440 +}
441 +
442 +/// Strip the port from an authority, handling bracketed IPv6 literals.
443 +fn host_of_authority(authority: &str) -> &str {
444 + if let Some(end) = authority.strip_prefix('[').and_then(|r| r.find(']')) {
445 + return &authority[1..=end];
446 + }
447 + authority.split(':').next().unwrap_or(authority)
448 +}
449 +
450 +fn is_loopback_host(host: &str) -> bool {
451 + matches!(host, "localhost" | "127.0.0.1" | "::1")
452 +}
453 +
454 +/// Extract `scheme://host[:port]` from an absolute URL.
455 +fn script_origin_of(url: &str) -> Option<String> {
456 + let (scheme, rest) = url.split_once("://")?;
457 + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
458 + if authority.is_empty() {
459 + return None;
460 + }
461 + Some(format!("{}://{}", scheme, authority))
462 +}
463 +
464 +/// Escape the five characters that could break out of a double-quoted HTML
465 +/// attribute. Defence in depth: [`AnalyticsConfig::validate`] already rejects
466 +/// every one of them.
467 +fn escape_html_attribute(value: &str) -> String {
468 + let mut out = String::with_capacity(value.len());
469 + for c in value.chars() {
470 + match c {
471 + '&' => out.push_str("&amp;"),
472 + '<' => out.push_str("&lt;"),
473 + '>' => out.push_str("&gt;"),
474 + '"' => out.push_str("&quot;"),
475 + '\'' => out.push_str("&#x27;"),
476 + _ => out.push(c),
477 + }
478 + }
479 + out
480 +}
481 +
482 +/// Shared analytics state, injected as an Axum `Extension`.
483 +///
484 +/// The snippet is rendered when the state is loaded and whenever
485 +/// [`Self::reload`] runs — never while serving a request. [`Self::snippet`]
486 +/// is therefore a plain `Arc` clone with no database round-trip, which is
487 +/// what makes the helper affordable on every page render.
488 +#[derive(Debug)]
489 +pub struct AnalyticsState {
490 + /// Configuration from the environment or the builder. Holds the script
491 + /// URL, which the database may not override.
492 + base: AnalyticsConfig,
493 + /// Pre-rendered snippet, `None` when tracking is off.
494 + rendered: RwLock<Arc<Option<String>>>,
495 +}
496 +
497 +impl AnalyticsState {
498 + /// Build the state from `base`, applying the `app_settings` overrides.
499 + ///
500 + /// # Errors
501 + ///
502 + /// Returns [`AppError`] if the settings cannot be read.
503 + pub async fn load(db: &DatabaseConnection, base: AnalyticsConfig) -> Result<Self, AppError> {
504 + let effective = Self::resolve(db, &base).await?;
505 + Ok(Self {
506 + base,
507 + rendered: RwLock::new(Arc::new(effective.render_snippet())),
508 + })
509 + }
510 +
511 + /// Build the state without consulting the database.
512 + ///
513 + /// Used when the framework runs without persisted settings, and in tests.
514 + pub fn from_config(base: AnalyticsConfig) -> Self {
515 + let rendered = base.render_snippet();
516 + Self {
517 + base,
518 + rendered: RwLock::new(Arc::new(rendered)),
519 + }
520 + }
521 +
522 + /// Re-read the `app_settings` overrides and re-render the snippet.
523 + ///
524 + /// Call this after an administrator saves the settings form so the change
525 + /// takes effect without a restart.
526 + ///
527 + /// # Errors
528 + ///
529 + /// Returns [`AppError`] if the settings cannot be read.
530 + pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError> {
531 + let effective = Self::resolve(db, &self.base).await?;
532 + let snippet = Arc::new(effective.render_snippet());
533 + match self.rendered.write() {
534 + Ok(mut guard) => *guard = snippet,
535 + // A poisoned lock means a previous writer panicked. The value is
536 + // still structurally sound (an Arc<Option<String>>), so recover
537 + // rather than propagate the panic to every subsequent request.
538 + Err(poisoned) => *poisoned.into_inner() = snippet,
539 + }
540 + Ok(())
541 + }
542 +
543 + /// The pre-rendered snippet, or `None` when tracking is off.
544 + pub fn snippet(&self) -> Arc<Option<String>> {
545 + match self.rendered.read() {
546 + Ok(guard) => Arc::clone(&guard),
547 + Err(poisoned) => Arc::clone(&poisoned.into_inner()),
548 + }
549 + }
550 +
551 + /// The `localStorage` key a visitor can set to opt out, if any.
552 + pub fn opt_out_storage_key(&self) -> Option<&str> {
553 + self.base.opt_out_storage_key.as_deref()
554 + }
555 +
556 + /// The script URL, which the database cannot override. Exposed so the
557 + /// administration screen can display it read-only.
558 + pub fn script_url(&self) -> Option<&str> {
559 + self.base.script_url.as_deref()
560 + }
561 +
562 + /// The environment/builder configuration, before any database override.
563 + ///
564 + /// The administration screen uses it as the starting point when checking
565 + /// a submitted form: the candidate must be validated against the real
566 + /// script URL and site-id attribute, not against defaults.
567 + pub fn base_config(&self) -> &AnalyticsConfig {
568 + &self.base
569 + }
570 +
571 + /// Merge `base` with the `app_settings` overrides.
572 + ///
573 + /// `script_url` is deliberately absent from the overrides: see the module
574 + /// documentation. An override that fails validation is discarded with a
575 + /// warning rather than taking the application down.
576 + async fn resolve(
577 + db: &DatabaseConnection,
578 + base: &AnalyticsConfig,
579 + ) -> Result<AnalyticsConfig, AppError> {
580 + let mut cfg = base.clone();
581 +
582 + cfg.enabled = AppSettingsService::get_bool(db, SETTING_ANALYTICS_ENABLED, base.enabled)
583 + .await
584 + .unwrap_or(base.enabled);
585 +
586 + if let Some(setting) = AppSettingsService::get_setting(db, SETTING_ANALYTICS_SITE_ID).await?
587 + {
588 + let value = setting.value.trim().to_string();
589 + cfg.site_id = if value.is_empty() { None } else { Some(value) };
590 + }
591 +
592 + if let Some(setting) =
593 + AppSettingsService::get_setting(db, SETTING_ANALYTICS_ATTRIBUTES).await?
594 + {
595 + let raw = setting.value.trim();
596 + if !raw.is_empty() {
597 + match parse_attributes_json(raw) {
598 + Ok(attributes) => cfg.attributes = attributes,
599 + Err(e) => tracing::warn!(
600 + error = %e,
601 + "ignoring stored analytics attributes: invalid payload"
602 + ),
603 + }
604 + }
605 + }
606 +
607 + if let Err(e) = cfg.validate() {
608 + tracing::warn!(
609 + error = %e,
610 + "stored analytics settings are invalid; falling back to the environment configuration"
611 + );
612 + return Ok(base.clone());
613 + }
614 +
615 + Ok(cfg)
616 + }
617 +}
618 +
619 +/// Everything a template needs to render audience measurement.
620 +///
621 +/// Obtained as an Axum extractor, then placed in the handler's template
622 +/// struct. Both fields are empty strings when the corresponding feature is
623 +/// off, which keeps the Askama side free of `Option` handling.
624 +///
625 +/// # Rendering
626 +///
627 +/// ```text
628 +/// {% if analytics.is_enabled() %}{{ analytics.snippet|safe }}{% endif %}
629 +/// ```
630 +///
631 +/// The `|safe` filter is correct here: [`AnalyticsConfig::render_snippet`]
632 +/// HTML-escapes every interpolated value, on top of the allow-list enforced
633 +/// by [`AnalyticsConfig::validate`]. Never put unvalidated user input into
634 +/// this field.
635 +#[derive(Clone, Debug, Default, PartialEq, Eq)]
636 +pub struct AnalyticsContext {
637 + /// The `<script>` tag, or an empty string when tracking is off.
638 + pub snippet: String,
639 + /// The `localStorage` opt-out key, or an empty string when the configured
640 + /// vendor exposes none.
641 + pub opt_out_storage_key: String,
642 + /// The configured script URL, or an empty string. Exposed so the
643 + /// administration screen can display it read-only; it is not a secret,
644 + /// since it appears in the page as soon as tracking is on.
645 + pub script_url: String,
646 +}
647 +
648 +impl AnalyticsContext {
649 + /// Build the context from the shared state, if the framework installed it.
650 + pub fn from_state(state: Option<&Arc<AnalyticsState>>) -> Self {
651 + let Some(state) = state else {
652 + return Self::default();
653 + };
654 + Self {
655 + snippet: state.snippet().as_deref().unwrap_or_default().to_string(),
656 + opt_out_storage_key: state.opt_out_storage_key().unwrap_or_default().to_string(),
657 + script_url: state.script_url().unwrap_or_default().to_string(),
658 + }
659 + }
660 +
661 + /// Whether a script URL is configured at all, whichever the runtime
662 + /// toggle. When false, the administration screen has nothing to offer.
663 + pub fn is_configured(&self) -> bool {
664 + !self.script_url.is_empty()
665 + }
666 +
667 + /// Whether a tracking script will be rendered.
668 + pub fn is_enabled(&self) -> bool {
669 + !self.snippet.is_empty()
670 + }
671 +
672 + /// Whether an opt-out control can be offered to the visitor.
673 + pub fn has_opt_out(&self) -> bool {
674 + !self.opt_out_storage_key.is_empty()
675 + }
676 +}
677 +
678 +impl<S> axum::extract::FromRequestParts<S> for AnalyticsContext
679 +where
680 + S: Send + Sync,
681 +{
682 + type Rejection = std::convert::Infallible;
683 +
684 + /// Never fails: an application built without analytics simply yields the
685 + /// empty context, so templates shared between configurations keep working.
686 + async fn from_request_parts(
687 + parts: &mut axum::http::request::Parts,
688 + _state: &S,
689 + ) -> Result<Self, Self::Rejection> {
690 + Ok(Self::from_state(parts.extensions.get::<Arc<AnalyticsState>>()))
691 + }
692 +}
693 +
694 +/// Handle on the shared [`AnalyticsState`], for the rare callers that need to
695 +/// mutate it — chiefly the administration screen, which calls
696 +/// [`AnalyticsState::reload`] after saving so the change takes effect without
697 +/// a restart.
698 +///
699 +/// `None` when the application was not built through `RustwardenBuilder`, so
700 +/// a consumer mounting the UI by hand still compiles and runs.
701 +#[derive(Clone, Debug)]
702 +pub struct AnalyticsHandle(pub Option<Arc<AnalyticsState>>);
703 +
704 +impl AnalyticsHandle {
705 + /// The environment/builder configuration, when analytics is wired.
706 + pub fn base_config(&self) -> Option<&AnalyticsConfig> {
707 + self.0.as_ref().map(|state| state.base_config())
708 + }
709 +
710 + /// Re-read the settings, if analytics is wired. A missing handle is not
711 + /// an error: there is simply nothing to refresh.
712 + ///
713 + /// # Errors
714 + ///
715 + /// Returns [`AppError`] if the settings cannot be read.
716 + pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError> {
717 + match &self.0 {
718 + Some(state) => state.reload(db).await,
719 + None => Ok(()),
720 + }
721 + }
722 +}
723 +
724 +impl<S> axum::extract::FromRequestParts<S> for AnalyticsHandle
725 +where
726 + S: Send + Sync,
727 +{
728 + type Rejection = std::convert::Infallible;
729 +
730 + async fn from_request_parts(
731 + parts: &mut axum::http::request::Parts,
732 + _state: &S,
733 + ) -> Result<Self, Self::Rejection> {
734 + Ok(Self(parts.extensions.get::<Arc<AnalyticsState>>().cloned()))
735 + }
736 +}
737 +
738 +#[cfg(test)]
739 +mod tests {
740 + use super::*;
741 +
742 + fn enabled_config() -> AnalyticsConfig {
743 + AnalyticsConfig::default()
744 + .with_script_url("https://stats.example.org/api/script.js")
745 + .with_site_id("abc123")
746 + .enabled(true)
747 + }
748 +
749 + #[test]
750 + fn default_is_disabled_and_renders_nothing() {
751 + let cfg = AnalyticsConfig::default();
752 + assert!(!cfg.enabled);
753 + assert!(cfg.validate().is_ok());
754 + assert!(cfg.render_snippet().is_none());
755 + assert!(cfg.script_origin().is_none());
756 + }
757 +
758 + #[test]
759 + fn disabled_config_renders_nothing_even_with_a_url() {
760 + let cfg = enabled_config().enabled(false);
761 + assert!(cfg.render_snippet().is_none());
762 + }
763 +
764 + #[test]
765 + fn enabled_without_url_is_rejected() {
766 + let cfg = AnalyticsConfig::default().enabled(true);
767 + assert_eq!(cfg.validate(), Err(AnalyticsConfigError::MissingScriptUrl));
768 + }
769 +
770 + #[test]
771 + fn renders_expected_tag() {
772 + let cfg = enabled_config().with_attribute("data-consent", "opt-in");
773 + assert_eq!(
774 + cfg.render_snippet().as_deref(),
775 + Some(
776 + r#"<script src="https://stats.example.org/api/script.js" data-site-id="abc123" data-consent="opt-in" defer></script>"#
777 + )
778 + );
779 + }
780 +
781 + #[test]
782 + fn defer_can_be_turned_off() {
783 + let mut cfg = enabled_config();
784 + cfg.defer = false;
785 + let snippet = cfg.render_snippet().unwrap_or_default();
786 + assert!(!snippet.contains("defer"));
787 + }
788 +
789 + #[test]
790 + fn attribute_order_is_deterministic() {
791 + let cfg = enabled_config()
792 + .with_attribute("data-zulu", "1")
793 + .with_attribute("data-alpha", "2");
794 + let snippet = cfg.render_snippet().unwrap_or_default();
795 + let alpha = snippet.find("data-alpha").unwrap_or(usize::MAX);
796 + let zulu = snippet.find("data-zulu").unwrap_or(0);
797 + assert!(alpha < zulu, "attributes must render in sorted order");
798 + }
799 +
800 + // --- Security: injection ------------------------------------------------
801 +
802 + #[test]
803 + fn site_id_carrying_markup_is_rejected() {
804 + let cfg = enabled_config().with_site_id(r#""><script>alert(1)</script>"#);
805 + assert!(matches!(
806 + cfg.validate(),
807 + Err(AnalyticsConfigError::InvalidAttributeValue { .. })
808 + ));
809 + }
810 +
811 + #[test]
812 + fn markup_that_bypasses_validation_is_still_escaped() {
813 + // Defence in depth: build the struct directly, skipping `validate`.
814 + let mut cfg = enabled_config();
815 + cfg.site_id = Some(r#""><script>alert(1)</script>"#.to_string());
816 + let snippet = cfg.render_snippet().unwrap_or_default();
817 + assert!(!snippet.contains("<script>alert(1)"));
818 + assert!(snippet.contains("&quot;&gt;&lt;script&gt;"));
819 + }
820 +
821 + #[test]
822 + fn event_handler_attributes_are_rejected() {
823 + for name in ["onload", "onerror", "src", "integrity", "data-x onload"] {
824 + let cfg = enabled_config().with_attribute(name, "x");
825 + assert!(
826 + matches!(
827 + cfg.validate(),
828 + Err(AnalyticsConfigError::InvalidAttributeName(_))
829 + ),
830 + "attribute `{}` must be rejected",
831 + name
832 + );
833 + }
834 + }
835 +
836 + #[test]
837 + fn site_id_attribute_cannot_be_duplicated() {
838 + let cfg = enabled_config().with_attribute(DEFAULT_SITE_ID_ATTRIBUTE, "other");
839 + assert!(matches!(
840 + cfg.validate(),
841 + Err(AnalyticsConfigError::DuplicateAttribute(_))
842 + ));
843 + }
844 +
845 + // --- Security: URL ------------------------------------------------------
846 +
847 + #[test]
848 + fn plain_http_is_rejected_off_loopback() {
849 + let cfg = AnalyticsConfig::default()
850 + .with_script_url("http://stats.example.org/script.js")
851 + .enabled(true);
852 + assert!(matches!(
853 + cfg.validate(),
854 + Err(AnalyticsConfigError::InvalidScriptUrl(_))
855 + ));
856 + }
857 +
858 + #[test]
859 + fn plain_http_is_allowed_on_loopback() {
860 + for url in [
861 + "http://localhost:3001/script.js",
862 + "http://127.0.0.1:3001/script.js",
863 + ] {
864 + let cfg = AnalyticsConfig::default()
865 + .with_script_url(url)
866 + .enabled(true);
867 + assert!(cfg.validate().is_ok(), "{} should be accepted", url);
868 + }
869 + }
870 +
871 + #[test]
872 + fn malformed_urls_are_rejected() {
873 + for url in [
874 + "/relative/script.js",
875 + "https://",
876 + "javascript:alert(1)",
877 + "https://user:pass@evil.example/script.js",
878 + "https://stats.example.org/../script.js",
879 + "https://stats.example.org/script.js\" onload=\"alert(1)",
880 + ] {
881 + let cfg = AnalyticsConfig::default()
882 + .with_script_url(url)
883 + .enabled(true);
884 + assert!(
885 + matches!(
886 + cfg.validate(),
887 + Err(AnalyticsConfigError::InvalidScriptUrl(_))
888 + ),
889 + "{} should be rejected",
890 + url
891 + );
892 + }
893 + }
894 +
895 + // --- Origin -------------------------------------------------------------
896 +
897 + #[test]
898 + fn script_origin_drops_path_and_keeps_port() {
899 + let cases = [
900 + ("https://stats.example.org/api/script.js", "https://stats.example.org"),
901 + ("https://stats.example.org:8443/s.js", "https://stats.example.org:8443"),
902 + ("https://a.b.c.example.org/s.js?v=1", "https://a.b.c.example.org"),
903 + ("http://localhost:3001/s.js", "http://localhost:3001"),
904 + ];
905 + for (url, expected) in cases {
906 + let cfg = AnalyticsConfig::default().with_script_url(url);
907 + assert_eq!(cfg.script_origin().as_deref(), Some(expected), "for {}", url);
908 + }
909 + }
910 +
911 + #[test]
912 + fn script_origin_is_available_even_when_disabled() {
913 + // The CSP is built at startup and must allow the origin regardless of
914 + // the runtime toggle, which may be flipped on later from the DB.
915 + let cfg = enabled_config().enabled(false);
916 + assert_eq!(
917 + cfg.script_origin().as_deref(),
918 + Some("https://stats.example.org")
919 + );
920 + }
921 +
922 + // --- Opt-out key --------------------------------------------------------
923 +
924 + #[test]
925 + fn opt_out_key_charset_is_enforced() {
926 + let ok = enabled_config().with_opt_out_storage_key("disable-rybbit");
927 + assert!(ok.validate().is_ok());
928 +
929 + let bad = enabled_config().with_opt_out_storage_key("disable rybbit<>");
930 + assert!(matches!(
931 + bad.validate(),
932 + Err(AnalyticsConfigError::InvalidOptOutKey(_))
933 + ));
934 + }
935 +
936 + // --- JSON attributes ----------------------------------------------------
937 +
938 + #[test]
939 + fn attributes_json_is_parsed() {
940 + let parsed = parse_attributes_json(r#"{"data-consent":"opt-in"}"#).unwrap_or_default();
941 + assert_eq!(parsed.get("data-consent").map(String::as_str), Some("opt-in"));
942 + }
943 +
944 + #[test]
945 + fn attributes_json_rejects_non_objects_and_non_strings() {
946 + assert!(parse_attributes_json("[1,2]").is_err());
947 + assert!(parse_attributes_json(r#"{"data-x":42}"#).is_err());
948 + assert!(parse_attributes_json("not json").is_err());
949 + }
950 +
951 + // --- State --------------------------------------------------------------
952 +
953 + #[test]
954 + fn state_pre_renders_the_snippet() {
955 + let state = AnalyticsState::from_config(enabled_config());
956 + assert!(state.snippet().is_some());
957 + assert_eq!(state.script_url(), Some("https://stats.example.org/api/script.js"));
958 + }
959 +
960 + #[test]
961 + fn state_of_a_disabled_config_yields_nothing() {
962 + let state = AnalyticsState::from_config(AnalyticsConfig::default());
963 + assert!(state.snippet().is_none());
964 + assert!(state.opt_out_storage_key().is_none());
965 + }
966 +
967 + // --- Template context ---------------------------------------------------
968 +
969 + #[test]
970 + fn context_without_state_is_empty() {
971 + let ctx = AnalyticsContext::from_state(None);
972 + assert!(!ctx.is_enabled());
973 + assert!(!ctx.has_opt_out());
974 + assert_eq!(ctx.snippet, "");
975 + assert_eq!(ctx.opt_out_storage_key, "");
976 + }
977 +
978 + #[test]
979 + fn context_carries_snippet_and_opt_out_key() {
980 + let state = Arc::new(AnalyticsState::from_config(
981 + enabled_config().with_opt_out_storage_key("disable-rybbit"),
982 + ));
983 + let ctx = AnalyticsContext::from_state(Some(&state));
984 +
985 + assert!(ctx.is_enabled());
986 + assert!(ctx.has_opt_out());
987 + assert!(ctx.snippet.contains("data-site-id=\"abc123\""));
988 + assert_eq!(ctx.opt_out_storage_key, "disable-rybbit");
989 + }
990 +
991 + #[test]
992 + fn context_of_disabled_analytics_still_exposes_nothing() {
993 + let state = Arc::new(AnalyticsState::from_config(enabled_config().enabled(false)));
994 + let ctx = AnalyticsContext::from_state(Some(&state));
995 + assert!(!ctx.is_enabled());
996 + }
997 +}
M crates/rustwarden-core/src/config/mod.rs
+2 -0
@@ -3,6 +3,8 @@
3 3 //! Server, database, authentication, email, and admin configuration.
4 4 /// Admin account bootstrapping configuration.
5 5 pub mod admin;
6 +/// Audience-measurement (analytics) configuration.
7 +pub mod analytics;
6 8 /// Application-level configuration (name, base URL, etc.).
7 9 pub mod app;
8 10 /// Authentication configuration (JWT, sessions, rate limits).
M crates/rustwarden-core/src/config/security_headers.rs
+183 -0
@@ -74,6 +74,102 @@ impl SecurityHeadersConfig {
74 74 self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin"));
75 75 self
76 76 }
77 +
78 + /// Allow `origin` to serve scripts and receive beacons, by adding it to
79 + /// both the `script-src` and `connect-src` directives of the CSP.
80 + ///
81 + /// A third-party analytics script needs both: the browser fetches the
82 + /// script from that origin, then the script posts events back to it.
83 + ///
84 + /// The operation is idempotent — an origin already present is not
85 + /// duplicated — and leaves every other directive untouched. A target
86 + /// directive absent from the policy is created from the sources of
87 + /// `default-src`, mirroring the fallback the browser itself applies.
88 + ///
89 + /// Wired automatically by `RustwardenBuilder::build` from
90 + /// [`crate::config::analytics::AnalyticsConfig::script_origin`]; consumers
91 + /// rarely need to call it directly.
92 + ///
93 + /// # Examples
94 + ///
95 + /// ```
96 + /// use rustwarden_core::config::security_headers::SecurityHeadersConfig;
97 + ///
98 + /// let cfg = SecurityHeadersConfig::default()
99 + /// .allowing_script_origin("https://stats.example.org");
100 + ///
101 + /// assert!(cfg.content_security_policy.contains(
102 + /// "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://stats.example.org"
103 + /// ));
104 + /// assert!(cfg.content_security_policy.contains(
105 + /// "connect-src 'self' https://stats.example.org"
106 + /// ));
107 + /// ```
108 + pub fn allowing_script_origin(mut self, origin: &str) -> Self {
109 + let origin = origin.trim();
110 + if origin.is_empty() {
111 + return self;
112 + }
113 + let policy = add_source_to_directives(
114 + &self.content_security_policy,
115 + origin,
116 + &["script-src", "connect-src"],
117 + );
118 + self.content_security_policy = Cow::Owned(policy);
119 + self
120 + }
121 +}
122 +
123 +/// Add `source` to each of `directives` in a CSP string.
124 +///
125 +/// Existing directives keep their original position and source order; a
126 +/// missing directive is appended, seeded with the sources of `default-src`.
127 +fn add_source_to_directives(policy: &str, source: &str, directives: &[&str]) -> String {
128 + let mut parsed: Vec<(String, Vec<String>)> = policy
129 + .split(';')
130 + .map(str::trim)
131 + .filter(|d| !d.is_empty())
132 + .filter_map(|directive| {
133 + let mut tokens = directive.split_whitespace();
134 + let name = tokens.next()?.to_string();
135 + Some((name, tokens.map(str::to_string).collect()))
136 + })
137 + .collect();
138 +
139 + let default_sources: Vec<String> = parsed
140 + .iter()
141 + .find(|(name, _)| name == "default-src")
142 + .map(|(_, sources)| sources.clone())
143 + .unwrap_or_default();
144 +
145 + for target in directives {
146 + match parsed.iter_mut().find(|(name, _)| name == target) {
147 + Some((_, sources)) => {
148 + if !sources.iter().any(|s| s == source) {
149 + sources.push(source.to_string());
150 + }
151 + }
152 + None => {
153 + let mut sources = default_sources.clone();
154 + if !sources.iter().any(|s| s == source) {
155 + sources.push(source.to_string());
156 + }
157 + parsed.push(((*target).to_string(), sources));
158 + }
159 + }
160 + }
161 +
162 + parsed
163 + .into_iter()
164 + .map(|(name, sources)| {
165 + if sources.is_empty() {
166 + name
167 + } else {
168 + format!("{} {}", name, sources.join(" "))
169 + }
170 + })
171 + .collect::<Vec<_>>()
172 + .join("; ")
77 173 }
78 174
79 175 #[cfg(test)]
@@ -126,4 +222,91 @@ mod tests {
126 222 CSP_WITHOUT_UNSAFE_EVAL
127 223 );
128 224 }
225 +
226 + // --- allowing_script_origin (analytics) ---------------------------------
227 +
228 + /// Extract the sources of one directive, for assertions.
229 + fn sources_of<'a>(policy: &'a str, directive: &str) -> Vec<&'a str> {
230 + policy
231 + .split(';')
232 + .map(str::trim)
233 + .find(|d| d.split_whitespace().next() == Some(directive))
234 + .map(|d| d.split_whitespace().skip(1).collect())
235 + .unwrap_or_default()
236 + }
237 +
238 + #[test]
239 + fn allowing_script_origin_adds_to_script_and_connect() {
240 + let cfg =
241 + SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org");
242 + let policy = &cfg.content_security_policy;
243 +
244 + assert!(sources_of(policy, "script-src").contains(&"https://stats.example.org"));
245 + assert!(sources_of(policy, "connect-src").contains(&"https://stats.example.org"));
246 + }
247 +
248 + #[test]
249 + fn allowing_script_origin_preserves_other_directives() {
250 + let cfg =
251 + SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org");
252 + let policy = &cfg.content_security_policy;
253 +
254 + assert_eq!(sources_of(policy, "default-src"), vec!["'self'"]);
255 + assert_eq!(sources_of(policy, "frame-ancestors"), vec!["'none'"]);
256 + assert_eq!(sources_of(policy, "img-src"), vec!["'self'", "data:"]);
257 + assert_eq!(sources_of(policy, "form-action"), vec!["'self'"]);
258 + // Existing script-src sources are kept, the origin is appended.
259 + assert_eq!(
260 + sources_of(policy, "script-src"),
261 + vec![
262 + "'self'",
263 + "'unsafe-inline'",
264 + "'unsafe-eval'",
265 + "https://stats.example.org"
266 + ]
267 + );
268 + }
269 +
270 + #[test]
271 + fn allowing_script_origin_is_idempotent() {
272 + let once =
273 + SecurityHeadersConfig::default().allowing_script_origin("https://stats.example.org");
274 + let twice = once.clone().allowing_script_origin("https://stats.example.org");
275 + assert_eq!(once.content_security_policy, twice.content_security_policy);
276 + }
277 +
278 + #[test]
279 + fn allowing_script_origin_creates_missing_directive_from_default_src() {
280 + let cfg = SecurityHeadersConfig {
281 + content_security_policy: Cow::Borrowed("default-src 'self' https://cdn.example.org"),
282 + ..SecurityHeadersConfig::default()
283 + }
284 + .allowing_script_origin("https://stats.example.org");
285 + let policy = &cfg.content_security_policy;
286 +
287 + // The browser would fall back to default-src, so the created
288 + // directive must not silently drop its sources.
289 + assert_eq!(
290 + sources_of(policy, "script-src"),
291 + vec![
292 + "'self'",
293 + "https://cdn.example.org",
294 + "https://stats.example.org"
295 + ]
296 + );
297 + assert_eq!(
298 + sources_of(policy, "connect-src"),
299 + vec![
300 + "'self'",
301 + "https://cdn.example.org",
302 + "https://stats.example.org"
303 + ]
304 + );
305 + }
306 +
307 + #[test]
308 + fn allowing_empty_origin_is_a_noop() {
309 + let cfg = SecurityHeadersConfig::default().allowing_script_origin(" ");
310 + assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
311 + }
129 312 }
M crates/rustwarden-core/src/lib.rs
+3 -0
@@ -51,6 +51,9 @@ pub mod prelude {
51 51 pub use crate::app::RustwardenApp;
52 52 pub use crate::auth::extractors::AuthUser;
53 53 pub use crate::auth::jwt::{Claims, JwtService};
54 + pub use crate::config::analytics::{
55 + AnalyticsConfig, AnalyticsContext, AnalyticsHandle, AnalyticsState,
56 + };
54 57 pub use crate::config::security_headers::SecurityHeadersConfig;
55 58 pub use crate::error::AppError;
56 59 pub use crate::hooks::RustwardenHooks;
M crates/rustwarden-core/src/services/app_settings_service.rs
+18 -0
@@ -239,6 +239,24 @@ impl AppSettingsService {
239 239 "INFO",
240 240 Some("Audit log level: DEBUG, INFO, WARN, ERROR"),
241 241 ),
242 + // Audience measurement. The script URL is deliberately absent:
243 + // it feeds the CSP, which is frozen at startup, so it stays an
244 + // environment/builder setting. See `config::analytics`.
245 + (
246 + "analytics_enabled",
247 + "false",
248 + Some("Enable the audience-measurement script"),
249 + ),
250 + (
251 + "analytics_site_id",
252 + "",
253 + Some("Site identifier handed to the analytics vendor"),
254 + ),
255 + (
256 + "analytics_attributes",
257 + "{}",
258 + Some("Extra data-* attributes for the analytics script (JSON object)"),
259 + ),
242 260 (
243 261 "password_min_length",
244 262 "8",
M crates/rustwarden-ui/locales/de.yml
+20 -0
@@ -454,6 +454,19 @@ admin:
454 454 oauth_discord_desc: "OAuth2"
455 455 oauth_microsoft_label: "Microsoft"
456 456 oauth_microsoft_desc: "OpenID Connect"
457 + # Audience measurement. The script URL is read-only here: it feeds
458 + # the CSP, which is frozen at startup (see config::analytics).
459 + analytics_title: 'Reichweitenmessung'
460 + analytics_desc: 'Tracking-Skript eines Drittanbieters, das in die Seiten eingebunden wird. Die Skriptadresse wird über eine Umgebungsvariable konfiguriert; die folgenden Einstellungen gelten ohne Neustart.'
461 + analytics_enabled_label: 'Reichweitenmessung aktivieren'
462 + analytics_enabled_desc: 'Wenn deaktiviert, wird den Besuchern kein Tracking-Skript ausgeliefert.'
463 + analytics_site_id_label: 'Site-Kennung'
464 + analytics_site_id_desc: 'Von Ihrem Dienst zur Reichweitenmessung bereitgestellt.'
465 + analytics_attributes_label: 'Zusätzliche Attribute'
466 + analytics_attributes_desc: 'Ein JSON-Objekt mit data-*-Attributen, zum Beispiel ein Paar data-consent / opt-in.'
467 + analytics_script_url_label: 'Skriptadresse'
468 + analytics_script_url_desc: 'Schreibgeschützt: Diese Adresse fließt in die Content Security Policy ein, die beim Start festgelegt wird. Konfiguration über ANALYTICS_SCRIPT_URL.'
469 + analytics_not_configured: 'Es ist kein Skript zur Reichweitenmessung konfiguriert. Setzen Sie ANALYTICS_SCRIPT_URL und starten Sie die Anwendung neu, um diesen Bereich zu aktivieren.'
457 470 users_list:
458 471 page_title: "Benutzerverwaltung - Admin - RustCloud"
459 472 title: "Benutzerverwaltung"
@@ -860,3 +873,10 @@ http_errors:
860 873 code_label: "Fehler 404"
861 874 home: "Zur Startseite"
862 875 login: "Anmelden"
876 +
877 +analytics:
878 + opt_out:
879 + hint: 'Sie können der Reichweitenmessung jederzeit widersprechen. Ihre Wahl wird in Ihrem Browser gespeichert und gilt ab dem nächsten Seitenaufruf.'
880 + opt_out_label: 'Reichweitenmessung deaktivieren'
881 + opt_in_label: 'Reichweitenmessung wieder aktivieren'
882 + opted_out_badge: 'Sie haben widersprochen'
M crates/rustwarden-ui/locales/en.yml
+20 -0
@@ -456,6 +456,19 @@ admin:
456 456 oauth_discord_desc: "OAuth2"
457 457 oauth_microsoft_label: "Microsoft"
458 458 oauth_microsoft_desc: "OpenID Connect"
459 + # Audience measurement. The script URL is read-only here: it feeds
460 + # the CSP, which is frozen at startup (see config::analytics).
461 + analytics_title: 'Audience measurement'
462 + analytics_desc: 'Third-party tracking script injected into pages. The script address is configured through an environment variable; the settings below apply without a restart.'
463 + analytics_enabled_label: 'Enable audience measurement'
464 + analytics_enabled_desc: 'When disabled, no tracking script is sent to visitors.'
465 + analytics_site_id_label: 'Site identifier'
466 + analytics_site_id_desc: 'Provided by your audience-measurement service.'
467 + analytics_attributes_label: 'Extra attributes'
468 + analytics_attributes_desc: 'A JSON object of data-* attributes, for example a data-consent / opt-in pair.'
469 + analytics_script_url_label: 'Script address'
470 + analytics_script_url_desc: 'Read-only: this address feeds the Content Security Policy, which is frozen at startup. Configure it through ANALYTICS_SCRIPT_URL.'
471 + analytics_not_configured: 'No audience-measurement script is configured. Set ANALYTICS_SCRIPT_URL and restart the application to enable this section.'
459 472 users_list:
460 473 page_title: "User Management - Admin - RustCloud"
461 474 title: "User Management"
@@ -873,3 +886,10 @@ http_errors:
873 886 code_label: "Error 404"
874 887 home: "Back to home"
875 888 login: "Sign in"
889 +
890 +analytics:
891 + opt_out:
892 + hint: 'You can object to audience measurement at any time. Your choice is stored in your browser and takes effect on the next page load.'
893 + opt_out_label: 'Opt out of audience measurement'
894 + opt_in_label: 'Re-enable audience measurement'
895 + opted_out_badge: 'You are opted out'
M crates/rustwarden-ui/locales/es.yml
+20 -0
@@ -454,6 +454,19 @@ admin:
454 454 oauth_discord_desc: "OAuth2"
455 455 oauth_microsoft_label: "Microsoft"
456 456 oauth_microsoft_desc: "OpenID Connect"
457 + # Audience measurement. The script URL is read-only here: it feeds
458 + # the CSP, which is frozen at startup (see config::analytics).
459 + analytics_title: 'Medición de audiencia'
460 + analytics_desc: 'Script de seguimiento de terceros inyectado en las páginas. La dirección del script se configura mediante una variable de entorno; los ajustes siguientes se aplican sin reiniciar.'
461 + analytics_enabled_label: 'Activar la medición de audiencia'
462 + analytics_enabled_desc: 'Cuando está desactivado, no se envía ningún script de seguimiento a los visitantes.'
463 + analytics_site_id_label: 'Identificador del sitio'
464 + analytics_site_id_desc: 'Proporcionado por su servicio de medición de audiencia.'
465 + analytics_attributes_label: 'Atributos adicionales'
466 + analytics_attributes_desc: 'Un objeto JSON de atributos data-*, por ejemplo un par data-consent / opt-in.'
467 + analytics_script_url_label: 'Dirección del script'
468 + analytics_script_url_desc: 'Solo lectura: esta dirección alimenta la política de seguridad de contenido, fijada al arrancar. Configúrela mediante ANALYTICS_SCRIPT_URL.'
469 + analytics_not_configured: 'No hay ningún script de medición de audiencia configurado. Defina ANALYTICS_SCRIPT_URL y reinicie la aplicación para activar esta sección.'
457 470 users_list:
458 471 page_title: "Gestión de Usuarios - Admin - RustCloud"
459 472 title: "Gestión de Usuarios"
@@ -859,3 +872,10 @@ http_errors:
859 872 code_label: "Error 404"
860 873 home: "Volver al inicio"
861 874 login: "Iniciar sesión"
875 +
876 +analytics:
877 + opt_out:
878 + hint: 'Puede oponerse a la medición de audiencia en cualquier momento. Su elección se guarda en su navegador y surte efecto en la próxima carga de página.'
879 + opt_out_label: 'Excluirme de la medición de audiencia'
880 + opt_in_label: 'Reactivar la medición de audiencia'
881 + opted_out_badge: 'Está excluido'
M crates/rustwarden-ui/locales/fr.yml
+20 -0
@@ -456,6 +456,19 @@ admin:
456 456 oauth_discord_desc: "OAuth2"
457 457 oauth_microsoft_label: "Microsoft"
458 458 oauth_microsoft_desc: "OpenID Connect"
459 + # Audience measurement. The script URL is read-only here: it feeds
460 + # the CSP, which is frozen at startup (see config::analytics).
461 + analytics_title: 'Mesure d''audience'
462 + analytics_desc: 'Script de suivi tiers injecté dans les pages. L''adresse du script se configure par variable d''environnement ; les réglages ci-dessous s''appliquent sans redémarrage.'
463 + analytics_enabled_label: 'Activer la mesure d''audience'
464 + analytics_enabled_desc: 'Lorsque désactivé, aucun script de suivi n''est envoyé aux visiteurs.'
465 + analytics_site_id_label: 'Identifiant du site'
466 + analytics_site_id_desc: 'Fourni par votre service de mesure d''audience.'
467 + analytics_attributes_label: 'Attributs supplémentaires'
468 + analytics_attributes_desc: 'Objet JSON d''attributs data-*, par exemple une paire data-consent / opt-in.'
469 + analytics_script_url_label: 'Adresse du script'
470 + analytics_script_url_desc: 'Lecture seule : cette adresse alimente la politique de sécurité du contenu, figée au démarrage. Elle se configure via ANALYTICS_SCRIPT_URL.'
471 + analytics_not_configured: 'Aucun script de mesure d''audience n''est configuré. Définissez ANALYTICS_SCRIPT_URL puis redémarrez l''application pour activer cette section.'
459 472 users_list:
460 473 page_title: "Gestion des Utilisateurs - Admin - RustCloud"
461 474 title: "Gestion des Utilisateurs"
@@ -974,3 +987,10 @@ http_errors:
974 987 code_label: "Erreur 404"
975 988 home: "Retour à l'accueil"
976 989 login: "Se connecter"
990 +
991 +analytics:
992 + opt_out:
993 + hint: 'Vous pouvez vous opposer à tout moment à la mesure d''audience. Votre choix est enregistré dans votre navigateur et prend effet au prochain chargement de page.'
994 + opt_out_label: 'M''opposer à la mesure d''audience'
995 + opt_in_label: 'Réactiver la mesure d''audience'
996 + opted_out_badge: 'Vous êtes opposé au suivi'
M crates/rustwarden-ui/locales/it.yml
+20 -0
@@ -454,6 +454,19 @@ admin:
454 454 oauth_discord_desc: "OAuth2"
455 455 oauth_microsoft_label: "Microsoft"
456 456 oauth_microsoft_desc: "OpenID Connect"
457 + # Audience measurement. The script URL is read-only here: it feeds
458 + # the CSP, which is frozen at startup (see config::analytics).
459 + analytics_title: 'Misurazione del pubblico'
460 + analytics_desc: 'Script di tracciamento di terze parti inserito nelle pagine. L''indirizzo dello script si configura tramite una variabile d''ambiente; le impostazioni seguenti si applicano senza riavvio.'
461 + analytics_enabled_label: 'Attiva la misurazione del pubblico'
462 + analytics_enabled_desc: 'Quando è disattivata, nessuno script di tracciamento viene inviato ai visitatori.'
463 + analytics_site_id_label: 'Identificativo del sito'
464 + analytics_site_id_desc: 'Fornito dal tuo servizio di misurazione del pubblico.'
465 + analytics_attributes_label: 'Attributi aggiuntivi'
466 + analytics_attributes_desc: 'Un oggetto JSON di attributi data-*, per esempio una coppia data-consent / opt-in.'
467 + analytics_script_url_label: 'Indirizzo dello script'
468 + analytics_script_url_desc: 'Sola lettura: questo indirizzo alimenta la Content Security Policy, fissata all''avvio. Si configura tramite ANALYTICS_SCRIPT_URL.'
469 + analytics_not_configured: 'Nessuno script di misurazione del pubblico è configurato. Imposta ANALYTICS_SCRIPT_URL e riavvia l''applicazione per attivare questa sezione.'
457 470 users_list:
458 471 page_title: "Gestione Utenti - Admin - RustCloud"
459 472 title: "Gestione Utenti"
@@ -966,3 +979,10 @@ http_errors:
966 979 code_label: "Errore 404"
967 980 home: "Torna alla home"
968 981 login: "Accedi"
982 +
983 +analytics:
984 + opt_out:
985 + hint: 'Puoi opporti alla misurazione del pubblico in qualsiasi momento. La tua scelta è salvata nel browser e ha effetto al prossimo caricamento della pagina.'
986 + opt_out_label: 'Opporsi alla misurazione del pubblico'
987 + opt_in_label: 'Riattivare la misurazione del pubblico'
988 + opted_out_badge: 'Hai espresso opposizione'
M crates/rustwarden-ui/locales/pt.yml
+20 -0
@@ -454,6 +454,19 @@ admin:
454 454 oauth_discord_desc: "OAuth2"
455 455 oauth_microsoft_label: "Microsoft"
456 456 oauth_microsoft_desc: "OpenID Connect"
457 + # Audience measurement. The script URL is read-only here: it feeds
458 + # the CSP, which is frozen at startup (see config::analytics).
459 + analytics_title: 'Medição de audiência'
460 + analytics_desc: 'Script de rastreamento de terceiros injetado nas páginas. O endereço do script é configurado por variável de ambiente; as definições abaixo aplicam-se sem reiniciar.'
461 + analytics_enabled_label: 'Ativar a medição de audiência'
462 + analytics_enabled_desc: 'Quando desativado, nenhum script de rastreamento é enviado aos visitantes.'
463 + analytics_site_id_label: 'Identificador do site'
464 + analytics_site_id_desc: 'Fornecido pelo seu serviço de medição de audiência.'
465 + analytics_attributes_label: 'Atributos adicionais'
466 + analytics_attributes_desc: 'Um objeto JSON de atributos data-*, por exemplo um par data-consent / opt-in.'
467 + analytics_script_url_label: 'Endereço do script'
468 + analytics_script_url_desc: 'Apenas leitura: este endereço alimenta a Content Security Policy, fixada no arranque. Configure-o através de ANALYTICS_SCRIPT_URL.'
469 + analytics_not_configured: 'Nenhum script de medição de audiência está configurado. Defina ANALYTICS_SCRIPT_URL e reinicie a aplicação para ativar esta secção.'
457 470 users_list:
458 471 page_title: "Gerenciamento de Usuários - Admin - RustCloud"
459 472 title: "Gerenciamento de Usuários"
@@ -966,3 +979,10 @@ http_errors:
966 979 code_label: "Erro 404"
967 980 home: "Voltar ao início"
968 981 login: "Entrar"
982 +
983 +analytics:
984 + opt_out:
985 + hint: 'Pode opor-se à medição de audiência a qualquer momento. A sua escolha é guardada no navegador e tem efeito no próximo carregamento da página.'
986 + opt_out_label: 'Opor-me à medição de audiência'
987 + opt_in_label: 'Reativar a medição de audiência'
988 + opted_out_badge: 'Está excluído'
A crates/rustwarden-ui/src/analytics.rs
+97 -0
@@ -0,0 +1,97 @@
1 +//! Template context for the audience-measurement opt-out component.
2 +//!
3 +//! The framework side of audience measurement lives in
4 +//! [`rustwarden_core::config::analytics`]: configuration, snippet rendering
5 +//! and the CSP allowance. This module only carries what the reference UI
6 +//! needs to render `components/analytics_opt_out.html`.
7 +
8 +use rustwarden_core::config::analytics::AnalyticsContext;
9 +use rustwarden_core::i18n::I18nService;
10 +
11 +/// Everything `components/analytics_opt_out.html` needs.
12 +///
13 +/// Built from the [`AnalyticsContext`] extractor plus the translated labels,
14 +/// following the convention of this crate: templates receive ready-made
15 +/// `String`s rather than calling into i18n themselves.
16 +///
17 +/// # Example
18 +///
19 +/// ```rust,ignore
20 +/// let analytics_opt_out =
21 +/// AnalyticsOptOutContext::build(&analytics, &i18n_service, locale.as_str()).await;
22 +/// ```
23 +///
24 +/// Then, in the template: `{% include "components/analytics_opt_out.html" %}`.
25 +#[derive(Debug, Clone, Default)]
26 +pub struct AnalyticsOptOutContext {
27 + /// `localStorage` key toggled by the component. Empty when the configured
28 + /// vendor exposes no opt-out, in which case nothing is rendered.
29 + pub storage_key: String,
30 + /// Sentence explaining the choice and when it takes effect.
31 + pub hint: String,
32 + /// Label of the button while measurement is active.
33 + pub opt_out_label: String,
34 + /// Label of the button once the visitor has opted out.
35 + pub opt_in_label: String,
36 + /// Badge shown next to the button while opted out.
37 + pub opted_out_badge: String,
38 +}
39 +
40 +impl AnalyticsOptOutContext {
41 + /// Build the context, translating the labels into `locale`.
42 + ///
43 + /// Returns an empty context — rendering nothing — when analytics is not
44 + /// configured or the vendor exposes no opt-out key, so a page can include
45 + /// the component unconditionally.
46 + pub async fn build(
47 + analytics: &AnalyticsContext,
48 + i18n_service: &I18nService,
49 + locale: &str,
50 + ) -> Self {
51 + if !analytics.has_opt_out() {
52 + return Self::default();
53 + }
54 + Self {
55 + storage_key: analytics.opt_out_storage_key.clone(),
56 + hint: i18n_service
57 + .translate("analytics.opt_out.hint", locale)
58 + .await,
59 + opt_out_label: i18n_service
60 + .translate("analytics.opt_out.opt_out_label", locale)
61 + .await,
62 + opt_in_label: i18n_service
63 + .translate("analytics.opt_out.opt_in_label", locale)
64 + .await,
65 + opted_out_badge: i18n_service
66 + .translate("analytics.opt_out.opted_out_badge", locale)
67 + .await,
68 + }
69 + }
70 +
71 + /// Whether the component has an opt-out key to work with.
72 + ///
73 + /// Called from the template guard; an empty key means the whole component
74 + /// is skipped.
75 + pub fn has_key(&self) -> bool {
76 + !self.storage_key.is_empty()
77 + }
78 +}
79 +
80 +#[cfg(test)]
81 +mod tests {
82 + use super::*;
83 +
84 + #[test]
85 + fn default_context_renders_nothing() {
86 + assert!(!AnalyticsOptOutContext::default().has_key());
87 + }
88 +
89 + #[test]
90 + fn a_configured_key_enables_the_component() {
91 + let ctx = AnalyticsOptOutContext {
92 + storage_key: "disable-rybbit".to_string(),
93 + ..AnalyticsOptOutContext::default()
94 + };
95 + assert!(ctx.has_key());
96 + }
97 +}
M crates/rustwarden-ui/src/handlers/admin_handler.rs
+116 -0
@@ -1571,6 +1571,18 @@ struct AdminSettingsTemplate {
1571 1571 oauth_discord_desc: String,
1572 1572 oauth_microsoft_label: String,
1573 1573 oauth_microsoft_desc: String,
1574 + // Audience measurement section
1575 + analytics_title: String,
1576 + analytics_desc: String,
1577 + analytics_enabled_label: String,
1578 + analytics_enabled_desc: String,
1579 + analytics_site_id_label: String,
1580 + analytics_site_id_desc: String,
1581 + analytics_attributes_label: String,
1582 + analytics_attributes_desc: String,
1583 + analytics_script_url_label: String,
1584 + analytics_script_url_desc: String,
1585 + analytics_not_configured: String,
1574 1586 }
1575 1587
1576 1588 impl_template_response!(AdminSettingsTemplate);
@@ -1615,6 +1627,13 @@ struct AppSettingsTemplateContext {
1615 1627 oauth_microsoft_tenant: String,
1616 1628 // TOTP 2FA
1617 1629 totp_force_enabled: String,
1630 + // Audience measurement. `analytics_script_url` is read-only: it feeds the
1631 + // CSP, which is frozen at startup, so it stays an environment setting.
1632 + analytics_enabled: String,
1633 + analytics_site_id: String,
1634 + analytics_attributes: String,
1635 + analytics_script_url: String,
1636 + analytics_configured: bool,
1618 1637 }
1619 1638
1620 1639 /// GET /admin/settings - Application settings page
@@ -1623,6 +1642,7 @@ pub async fn get_app_settings(
1623 1642 Extension(i18n_service): Extension<Arc<I18nService>>,
1624 1643 Extension(locale): Extension<Locale>,
1625 1644 State(db): State<DatabaseConnection>,
1645 + analytics: rustwarden_core::config::analytics::AnalyticsContext,
1626 1646 session: Session,
1627 1647 ) -> Result<impl IntoResponse, AppError> {
1628 1648 let locale_str = locale.as_str();
@@ -1683,6 +1703,12 @@ pub async fn get_app_settings(
1683 1703 oauth_microsoft_tenant: get_val("oauth_microsoft_tenant", ""),
1684 1704 // TOTP 2FA
1685 1705 totp_force_enabled: get_val("totp_force_enabled", "false"),
1706 + // Audience measurement
1707 + analytics_enabled: get_val("analytics_enabled", "false"),
1708 + analytics_site_id: get_val("analytics_site_id", ""),
1709 + analytics_attributes: get_val("analytics_attributes", "{}"),
1710 + analytics_script_url: analytics.script_url.clone(),
1711 + analytics_configured: analytics.is_configured(),
1686 1712 };
1687 1713
1688 1714 let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await;
@@ -1937,6 +1963,39 @@ pub async fn get_app_settings(
1937 1963 oauth_microsoft_desc: i18n_service
1938 1964 .translate("admin.settings.oauth_microsoft_desc", locale_str)
1939 1965 .await,
1966 + analytics_title: i18n_service
1967 + .translate("admin.settings.analytics_title", locale_str)
1968 + .await,
1969 + analytics_desc: i18n_service
1970 + .translate("admin.settings.analytics_desc", locale_str)
1971 + .await,
1972 + analytics_enabled_label: i18n_service
1973 + .translate("admin.settings.analytics_enabled_label", locale_str)
1974 + .await,
1975 + analytics_enabled_desc: i18n_service
1976 + .translate("admin.settings.analytics_enabled_desc", locale_str)
1977 + .await,
1978 + analytics_site_id_label: i18n_service
1979 + .translate("admin.settings.analytics_site_id_label", locale_str)
1980 + .await,
1981 + analytics_site_id_desc: i18n_service
1982 + .translate("admin.settings.analytics_site_id_desc", locale_str)
1983 + .await,
1984 + analytics_attributes_label: i18n_service
1985 + .translate("admin.settings.analytics_attributes_label", locale_str)
1986 + .await,
1987 + analytics_attributes_desc: i18n_service
1988 + .translate("admin.settings.analytics_attributes_desc", locale_str)
1989 + .await,
1990 + analytics_script_url_label: i18n_service
1991 + .translate("admin.settings.analytics_script_url_label", locale_str)
1992 + .await,
1993 + analytics_script_url_desc: i18n_service
1994 + .translate("admin.settings.analytics_script_url_desc", locale_str)
1995 + .await,
1996 + analytics_not_configured: i18n_service
1997 + .translate("admin.settings.analytics_not_configured", locale_str)
1998 + .await,
1940 1999 };
1941 2000
1942 2001 Ok(template)
@@ -2019,12 +2078,20 @@ pub struct AdminSettingsFormData {
2019 2078 // TOTP 2FA
2020 2079 /// Whether TOTP two-factor authentication is enforced for all users.
2021 2080 pub totp_force_enabled: Option<String>,
2081 + // Audience measurement
2082 + /// Whether the audience-measurement script is rendered.
2083 + pub analytics_enabled: Option<String>,
2084 + /// Site identifier handed to the analytics vendor.
2085 + pub analytics_site_id: Option<String>,
2086 + /// Extra `data-*` attributes, as a JSON object.
2087 + pub analytics_attributes: Option<String>,
2022 2088 }
2023 2089
2024 2090 /// PUT /admin/settings - Update app settings
2025 2091 pub async fn update_app_settings(
2026 2092 State(db): State<DatabaseConnection>,
2027 2093 Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>,
2094 + analytics: rustwarden_core::config::analytics::AnalyticsHandle,
2028 2095 axum::Form(form): axum::Form<AdminSettingsFormData>,
2029 2096 ) -> Result<impl IntoResponse, AppError> {
2030 2097 let user_id = Some(claims.sub);
@@ -2036,6 +2103,41 @@ pub async fn update_app_settings(
2036 2103 }
2037 2104 };
2038 2105
2106 + // Audience measurement: validate before persisting. These values end up
2107 + // inside a rendered <script> tag, so a value that would not survive
2108 + // `AnalyticsConfig::validate` must never reach the database.
2109 + let analytics_enabled = checkbox_val(&form.analytics_enabled);
2110 + let analytics_site_id = form
2111 + .analytics_site_id
2112 + .as_deref()
2113 + .unwrap_or_default()
2114 + .trim()
2115 + .to_string();
2116 + let analytics_attributes = {
2117 + let raw = form
2118 + .analytics_attributes
2119 + .as_deref()
2120 + .unwrap_or_default()
2121 + .trim();
2122 + if raw.is_empty() { "{}" } else { raw }.to_string()
2123 + };
2124 +
2125 + // Start from the environment configuration so the candidate is checked
2126 + // against the real script URL and site-id attribute, not against defaults.
2127 + let mut candidate = analytics.base_config().cloned().unwrap_or_default();
2128 + candidate.enabled = analytics_enabled == "true";
2129 + candidate.site_id = if analytics_site_id.is_empty() {
2130 + None
2131 + } else {
2132 + Some(analytics_site_id.clone())
2133 + };
2134 + candidate = candidate
2135 + .with_attributes_json(&analytics_attributes)
2136 + .map_err(|e| AppError::Validation(e.to_string()))?;
2137 + candidate
2138 + .validate()
2139 + .map_err(|e| AppError::Validation(e.to_string()))?;
2140 +
2039 2141 let settings_to_save: Vec<(&str, String)> = vec![
2040 2142 ("app_name", form.app_name.unwrap_or_default()),
2041 2143 ("app_domain", form.app_domain.unwrap_or_default()),
@@ -2148,6 +2250,10 @@ pub async fn update_app_settings(
2148 2250 ),
2149 2251 // TOTP 2FA
2150 2252 ("totp_force_enabled", checkbox_val(&form.totp_force_enabled)),
2253 + // Audience measurement (validated above)
2254 + ("analytics_enabled", analytics_enabled),
2255 + ("analytics_site_id", analytics_site_id),
2256 + ("analytics_attributes", analytics_attributes),
2151 2257 ];
2152 2258
2153 2259 let mut updated_count = 0u32;
@@ -2187,6 +2293,16 @@ pub async fn update_app_settings(
2187 2293 }
2188 2294 }
2189 2295
2296 + // Re-render the analytics snippet so the change takes effect immediately.
2297 + // A failure here only means the pages keep serving the previous snippet
2298 + // until the next restart — not a reason to report the save as failed.
2299 + if let Err(e) = analytics.reload(&db).await {
2300 + tracing::warn!(
2301 + error = %e,
2302 + "analytics settings saved but the in-memory snippet could not be refreshed"
2303 + );
2304 + }
2305 +
2190 2306 tracing::info!(
2191 2307 "Admin {} updated {} app settings",
2192 2308 claims.username,
M crates/rustwarden-ui/src/lib.rs
+2 -0
@@ -3,6 +3,8 @@
3 3 //! Default HTML user interface for the Rustwarden framework.
4 4 //! Provides login pages, profile management, admin panel, and all associated routes.
5 5
6 +/// Template context for the audience-measurement opt-out component.
7 +pub mod analytics;
6 8 /// Data Transfer Objects for request/response handling.
7 9 pub mod dto;
8 10 /// Custom Axum extractors (e.g. form with duplicate fields).
M crates/rustwarden-ui/templates/admin/settings.html
+83 -0
@@ -619,6 +619,89 @@
619 619 </div>
620 620 </div>
621 621
622 + <!-- Audience Measurement Card -->
623 + <div class="card bg-base-100 shadow-xl">
624 + <div class="card-body">
625 + <h2 class="card-title text-xl mb-4">
626 + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
627 + {{ analytics_title }}
628 + </h2>
629 + <p class="text-sm text-base-content/70 mb-4">{{ analytics_desc }}</p>
630 +
631 + {% if settings.analytics_configured %}
632 + <!-- Script URL: read-only, it feeds the CSP which is frozen at startup. -->
633 + <div class="form-control mb-4">
634 + <label class="label">
635 + <span class="label-text font-semibold">{{ analytics_script_url_label }}</span>
636 + </label>
637 + <input
638 + type="text"
639 + class="input input-bordered w-full font-mono text-sm"
640 + value="{{ settings.analytics_script_url }}"
641 + readonly
642 + disabled
643 + />
644 + <label class="label">
645 + <span class="label-text-alt text-base-content/60">{{ analytics_script_url_desc }}</span>
646 + </label>
647 + </div>
648 +
649 + <div class="form-control mb-4">
650 + <label class="label cursor-pointer justify-start gap-4">
651 + <input
652 + type="checkbox"
653 + name="analytics_enabled"
654 + value="true"
655 + class="toggle toggle-primary"
656 + {% if settings.analytics_enabled == "true" %}checked{% endif %}
657 + />
658 + <div>
659 + <span class="label-text font-semibold">{{ analytics_enabled_label }}</span>
660 + <p class="text-xs text-base-content/60 mt-1">{{ analytics_enabled_desc }}</p>
661 + </div>
662 + </label>
663 + </div>
664 +
665 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
666 + <div class="form-control">
667 + <label class="label">
668 + <span class="label-text font-semibold">{{ analytics_site_id_label }}</span>
669 + </label>
670 + <input
671 + type="text"
672 + name="analytics_site_id"
673 + class="input input-bordered w-full"
674 + value="{{ settings.analytics_site_id }}"
675 + />
676 + <label class="label">
677 + <span class="label-text-alt text-base-content/60">{{ analytics_site_id_desc }}</span>
678 + </label>
679 + </div>
680 +
681 + <div class="form-control">
682 + <label class="label">
683 + <span class="label-text font-semibold">{{ analytics_attributes_label }}</span>
684 + </label>
685 + <input
686 + type="text"
687 + name="analytics_attributes"
688 + class="input input-bordered w-full font-mono text-sm"
689 + value="{{ settings.analytics_attributes }}"
690 + />
691 + <label class="label">
692 + <span class="label-text-alt text-base-content/60">{{ analytics_attributes_desc }}</span>
693 + </label>
694 + </div>
695 + </div>
696 + {% else %}
697 + <div class="alert alert-info">
698 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
699 + <span class="text-sm">{{ analytics_not_configured }}</span>
700 + </div>
701 + {% endif %}
702 + </div>
703 + </div>
704 +
622 705 <!-- Action Buttons -->
623 706 <div class="card bg-base-200 shadow-xl">
624 707 <div class="card-body">
A crates/rustwarden-ui/templates/components/analytics_opt_out.html
+43 -0
@@ -0,0 +1,43 @@
1 +{# Visitor opt-out for audience measurement.
2 +
3 + Generalisation of the vendor-specific snippet: the localStorage key comes
4 + from `AnalyticsConfig::opt_out_storage_key` instead of being hardcoded, and
5 + the labels come from the framework's i18n rather than an inline cascade of
6 + locale tests.
7 +
8 + Most vendors no-op entirely when their `disable-<namespace>` key exists.
9 + The flag is read at script initialisation only, so a change takes effect on
10 + the next page load — hence the wording of the hint.
11 +
12 + The two labels are rendered as separate `x-show` spans rather than through
13 + `x-text`, because a translation containing an apostrophe ("M'opposer")
14 + would otherwise terminate the JavaScript string literal: Askama escapes it
15 + to `&#x27;`, which the browser decodes back to `'` before Alpine evaluates
16 + the expression.
17 +
18 + Requires the parent template to carry an `analytics_opt_out` field
19 + (`AnalyticsOptOutContext`). Renders nothing when no key is configured.
20 +#}
21 +{% if analytics_opt_out.has_key() %}
22 +<div class="mt-4 pt-4 border-t border-base-300"
23 + x-data="{
24 + storageKey: '{{ analytics_opt_out.storage_key }}',
25 + optedOut: false,
26 + init() { try { this.optedOut = localStorage.getItem(this.storageKey) !== null; } catch (e) {} },
27 + toggle() {
28 + try {
29 + if (this.optedOut) { localStorage.removeItem(this.storageKey); this.optedOut = false; }
30 + else { localStorage.setItem(this.storageKey, '1'); this.optedOut = true; }
31 + } catch (e) {}
32 + }
33 + }">
34 + <p class="text-sm text-base-content/70 mb-2">{{ analytics_opt_out.hint }}</p>
35 + <div class="flex items-center gap-3 flex-wrap">
36 + <button type="button" class="btn btn-sm btn-outline" @click="toggle()">
37 + <span x-show="!optedOut">{{ analytics_opt_out.opt_out_label }}</span>
38 + <span x-show="optedOut" x-cloak>{{ analytics_opt_out.opt_in_label }}</span>
39 + </button>
40 + <span class="badge badge-success badge-sm" x-show="optedOut" x-cloak>{{ analytics_opt_out.opted_out_badge }}</span>
41 + </div>
42 +</div>
43 +{% endif %}
M docs/SERVICES_REFERENCE.md
+217 -1
@@ -44,6 +44,7 @@
44 44 20. [Patterns communs](#20-patterns-communs)
45 45 21. [Fédération ActivityPub (features `fediverse*`)](#21-federation-activitypub-features-fediverse)
46 46 22. [Binaire `screen-select` — plan des écrans UI](#22-binaire-screen-select--plan-des-ecrans-ui)
47 +23. [Mesure d'audience](#23-mesure-daudience)
47 48
48 49 ---
49 50
@@ -862,6 +863,13 @@ Stockage cle-valeur en base pour la configuration dynamique.
862 863 | `password_min_length` | `"12"` | Longueur minimale du mot de passe |
863 864 | `password_require_uppercase` | `"true"` | Exiger une majuscule |
864 865 | `oauth_enabled` | `"false"` | OAuth active |
866 +| `analytics_enabled` | `"false"` | Mesure d'audience active — [§23](#23-mesure-daudience) |
867 +| `analytics_site_id` | `""` | Identifiant de site du fournisseur — [§23](#23-mesure-daudience) |
868 +| `analytics_attributes` | `"{}"` | Attributs `data-*` additionnels, objet JSON — [§23](#23-mesure-daudience) |
869 +
870 +> L'URL du script de suivi est **absente** de cette table : elle alimente la CSP,
871 +> figée au démarrage, et reste donc une variable d'environnement. Voir
872 +> [§23.2](#232-repartition-environnement--base).
865 873
866 874 ---
867 875
@@ -1362,12 +1370,20 @@ pub struct SecurityHeadersConfig {
1362 1370 |---------|--------------------|-------------|
1363 1371 | `SecurityHeadersConfig::default()` | — | CSP = `DEFAULT_CONTENT_SECURITY_POLICY`, COOP/CORP = `None` |
1364 1372 | `with_same_origin_isolation` | `(self) → Self` | Positionne COOP **et** CORP a `same-origin`. La CSP n'est pas touchee. |
1373 +| `allowing_script_origin` | `(self, origin: &str) → Self` | Ajoute `origin` a `script-src` **et** `connect-src`. Idempotent ; une directive absente est creee depuis `default-src`. Voir [§23.7](#237-csp). |
1365 1374 | `DEFAULT_CONTENT_SECURITY_POLICY` | `&'static str` | La CSP historique 1.0.1 (avec `'unsafe-inline'` et `'unsafe-eval'`) |
1366 1375 | `CSP_WITHOUT_UNSAFE_EVAL` | `&'static str` | Candidat **Phase 2**, identique au defaut moins `'unsafe-eval'`. **Non utilise** par `Default`. |
1367 1376 | `RustwardenBuilder::security_headers` | `(cfg: SecurityHeadersConfig) → Self` | Override au builder |
1368 1377
1378 +> ℹ️ **Interaction avec `.analytics(..)`.** Quand une URL de script de suivi est
1379 +> configuree, `build()` applique `allowing_script_origin` **a la fin**, sur la config
1380 +> resultante. L'ordre des appels `.analytics(..)` et `.security_headers(..)` au builder
1381 +> est donc indifferent : un `.security_headers(..)` tardif ne peut pas faire disparaitre
1382 +> silencieusement l'autorisation. Voir [§23.7](#237-csp).
1383 +
1369 1384 > ✅ **Le defaut est bit-identique a 1.0.1.** Un consommateur qui n'appelle
1370 -> jamais `.security_headers(..)` ne voit **aucun** changement d'en-tete — ni
1385 +> jamais `.security_headers(..)` — ni `.analytics(..)` avec une URL de script —
1386 +> ne voit **aucun** changement d'en-tete — ni
1371 1387 > amelioration automatique, ni regression. Le durcissement du defaut (retrait
1372 1388 > de `'unsafe-eval'`) est une **Phase 2** ulterieure, conditionnee a la migration
1373 1389 > vers la build Alpine CSP ([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)).
@@ -2024,3 +2040,203 @@ Ces sections sont délimitées par des marqueurs HTML
2024 2040 (`<!-- screen-select:security-guides -->`, `<!-- screen-select:security-per-screen -->`)
2025 2041 et régénérées à chaque exécution du wizard. `--check` ne modifie que le tableau :
2026 2042 les sections de sécurité sont préservées telles quelles.
2043 +
2044 +---
2045 +
2046 +## 23. Mesure d'audience
2047 +
2048 +**Module** : `rustwarden_core::config::analytics`
2049 +**Export** : prelude (`AnalyticsConfig`, `AnalyticsContext`, `AnalyticsHandle`, `AnalyticsState`)
2050 +**UI** : `rustwarden_ui::analytics::AnalyticsOptOutContext` + `components/analytics_opt_out.html`
2051 +
2052 +Support générique d'un script de suivi tiers, modélisé comme **une URL de script
2053 +plus un jeu d'attributs `data-*`**. Cette forme couvre Rybbit, Plausible, Umami,
2054 +Matomo-tag et leurs équivalents sans embarquer une ligne de code spécifique à un
2055 +fournisseur : seuls les noms d'attributs changent.
2056 +
2057 +Par défaut, **rien n'est activé** : aucun script rendu, CSP inchangée au caractère près.
2058 +
2059 +### 23.1 `AnalyticsConfig`
2060 +
2061 +```rust
2062 +#[derive(Clone, Debug, PartialEq, Eq)]
2063 +pub struct AnalyticsConfig {
2064 + pub enabled: bool,
2065 + pub script_url: Option<String>,
2066 + pub site_id: Option<String>,
2067 + pub site_id_attribute: String, // défaut "data-site-id"
2068 + pub attributes: BTreeMap<String, String>,
2069 + pub opt_out_storage_key: Option<String>,
2070 + pub defer: bool, // défaut true
2071 +}
2072 +```
2073 +
2074 +| Element | Signature | Description |
2075 +|---------|-----------|-------------|
2076 +| `default()` | — | Suivi désactivé, aucune URL. Ne rend rien, ne touche pas la CSP. |
2077 +| `from_env` | `() → Result<Self, AnalyticsConfigError>` | Lit les variables `ANALYTICS_*` (§23.4). Aucune variable posée ⇒ `default()`. |
2078 +| `validate` | `(&self) → Result<(), AnalyticsConfigError>` | Porte de sécurité : URL, charset des valeurs, noms d'attributs, clé d'opposition. |
2079 +| `script_origin` | `(&self) → Option<String>` | `scheme://host[:port]`, ce que la CSP doit autoriser. Disponible **même si `enabled == false`**. |
2080 +| `render_snippet` | `(&self) → Option<String>` | La balise `<script>`, ou `None` si désactivé / sans URL. Valeurs échappées en HTML. |
2081 +| `with_script_url` / `with_site_id` / `with_site_id_attribute` / `with_attribute` / `with_opt_out_storage_key` / `enabled` | `(self, ..) → Self` | Setters chaînables. |
2082 +| `with_attributes_json` | `(self, raw: &str) → Result<Self, AnalyticsConfigError>` | Remplace les attributs depuis un objet JSON de chaînes (format stocké en base). |
2083 +| `RustwardenBuilder::analytics` | `(cfg: AnalyticsConfig) → Self` | Câblage au builder. |
2084 +
2085 +### 23.2 Répartition environnement / base
2086 +
2087 +> ⚠️ **L'URL du script n'est pas modifiable depuis l'administration.** Elle vient
2088 +> de l'environnement ou du builder et reste figée jusqu'au redémarrage.
2089 +>
2090 +> `security_headers_middleware` capture sa configuration **une seule fois**, dans un
2091 +> `Arc` construit au `build()` et placé hors de toute couche `Extension`
2092 +> (contrainte assumée de la [RFC 0039](#en-tetes-de-securite-securityheadersconfig)).
2093 +> Une URL éditable à chaud laisserait la CSP désynchronisée jusqu'au redémarrage
2094 +> suivant — le script serait tout simplement bloqué par le navigateur.
2095 +>
2096 +> C'est aussi une propriété de sécurité : un compte d'administration compromis **ne
2097 +> peut pas élargir la CSP vers un domaine arbitraire**, ce qui constituerait une
2098 +> primitive d'exfiltration.
2099 +
2100 +| Réglage | Source | Modifiable à chaud |
2101 +|---------|--------|--------------------|
2102 +| URL du script | `ANALYTICS_SCRIPT_URL` / builder | **Non** (alimente la CSP) |
2103 +| Attribut portant l'identifiant | `ANALYTICS_SITE_ID_ATTRIBUTE` / builder | Non |
2104 +| Clé d'opposition `localStorage` | `ANALYTICS_OPT_OUT_STORAGE_KEY` / builder | Non |
2105 +| `defer` | `ANALYTICS_DEFER` / builder | Non |
2106 +| Activation | `ANALYTICS_ENABLED`, puis `app_settings.analytics_enabled` | **Oui** |
2107 +| Identifiant de site | `ANALYTICS_SITE_ID`, puis `app_settings.analytics_site_id` | **Oui** |
2108 +| Attributs `data-*` | `ANALYTICS_ATTRIBUTES`, puis `app_settings.analytics_attributes` | **Oui** |
2109 +
2110 +Une valeur stockée en base qui ne passerait pas `validate()` est **ignorée** avec un
2111 +`tracing::warn!`, et la configuration d'environnement reprend la main : un contenu
2112 +invalide en base ne peut ni casser le démarrage ni produire une balise douteuse.
2113 +
2114 +### 23.3 `AnalyticsState`, `AnalyticsContext`, `AnalyticsHandle`
2115 +
2116 +Le snippet est rendu **au démarrage et à chaque rechargement**, jamais pendant une
2117 +requête : `snippet()` n'est qu'un clone d'`Arc`, sans aller-retour base. C'est ce qui
2118 +rend l'appel abordable sur chaque rendu de page.
2119 +
2120 +| Element | Signature | Description |
2121 +|---------|-----------|-------------|
2122 +| `AnalyticsState::load` | `(db, base: AnalyticsConfig) → Result<Self, AppError>` | Applique les surcharges `app_settings` et pré-rend le snippet. Appelé par `build()`. |
2123 +| `AnalyticsState::from_config` | `(base: AnalyticsConfig) → Self` | Sans base de données (tests, apps sans réglages persistés). |
2124 +| `AnalyticsState::reload` | `(&self, db) → Result<(), AppError>` | Relit les réglages et re-rend. À appeler après une sauvegarde admin. |
2125 +| `AnalyticsState::snippet` | `(&self) → Arc<Option<String>>` | Le snippet pré-rendu. |
2126 +| `AnalyticsState::base_config` | `(&self) → &AnalyticsConfig` | La configuration d'environnement, avant surcharge. |
2127 +| `AnalyticsContext` | extracteur Axum, **infaillible** | `{ snippet, opt_out_storage_key, script_url }` + `is_enabled()`, `has_opt_out()`, `is_configured()`. Contexte vide si le framework n'a pas câblé l'analytics. |
2128 +| `AnalyticsHandle` | extracteur Axum, **infaillible** | `Option<Arc<AnalyticsState>>` + `reload()`, `base_config()`. Pour les rares appelants qui doivent rafraîchir l'état. |
2129 +
2130 +Les deux extracteurs sont infaillibles **par construction** : une application montée à
2131 +la main, sans passer par `RustwardenBuilder`, continue de compiler et de servir — elle
2132 +obtient simplement un contexte vide.
2133 +
2134 +### 23.4 Variables d'environnement
2135 +
2136 +| Variable | Défaut | Description |
2137 +|----------|--------|-------------|
2138 +| `ANALYTICS_SCRIPT_URL` | — | URL absolue du script. `https://` obligatoire, sauf hôte loopback. |
2139 +| `ANALYTICS_ENABLED` | `false` | `true` / `1` / `on` activent. Surchargeable en base. |
2140 +| `ANALYTICS_SITE_ID` | — | Identifiant de site. Surchargeable en base. |
2141 +| `ANALYTICS_SITE_ID_ATTRIBUTE` | `data-site-id` | Attribut portant l'identifiant (§23.5). |
2142 +| `ANALYTICS_ATTRIBUTES` | — | Objet JSON d'attributs `data-*`. Surchargeable en base. |
2143 +| `ANALYTICS_OPT_OUT_STORAGE_KEY` | — | Clé `localStorage` d'opposition. Vide ⇒ composant non rendu. |
2144 +| `ANALYTICS_DEFER` | `true` | `false` / `0` / `off` retirent l'attribut `defer`. |
2145 +
2146 +### 23.5 Correspondance des fournisseurs courants
2147 +
2148 +Le modèle est générique ; ce tableau ne fait que nommer l'attribut attendu par chacun.
2149 +
2150 +| Fournisseur | `ANALYTICS_SITE_ID_ATTRIBUTE` | Clé d'opposition usuelle |
2151 +|-------------|-------------------------------|--------------------------|
2152 +| Rybbit | `data-site-id` (défaut) | `disable-rybbit` |
2153 +| Umami | `data-website-id` | — |
2154 +| Plausible | `data-domain` | `plausible_ignore` |
2155 +| Matomo-tag | `data-site-id` | — |
2156 +
2157 +```bash
2158 +# Rybbit auto-hébergé, opposition activée
2159 +ANALYTICS_SCRIPT_URL=https://stats.example.org/api/script.js
2160 +ANALYTICS_SITE_ID=1d870795f7ae
2161 +ANALYTICS_OPT_OUT_STORAGE_KEY=disable-rybbit
2162 +ANALYTICS_ATTRIBUTES={"data-consent":"opt-in"}
2163 +ANALYTICS_ENABLED=true
2164 +```
2165 +
2166 +### 23.6 Contrat de rendu et `|safe`
2167 +
2168 +Le framework **ne pose pas** le script dans le layout : c'est au template consommateur
2169 +de l'appeler, via l'extracteur `AnalyticsContext`.
2170 +
2171 +```rust
2172 +// Handler
2173 +pub async fn ma_page(
2174 + analytics: AnalyticsContext,
2175 + // ..
2176 +) -> Result<impl IntoResponse, AppError> {
2177 + let template = MaPageTemplate { analytics, /* .. */ };
2178 + Ok(template)
2179 +}
2180 +```
2181 +
2182 +```html
2183 +{% if analytics.is_enabled() %}{{ analytics.snippet|safe }}{% endif %}
2184 +```
2185 +
2186 +> ℹ️ **Pourquoi `|safe` ici, alors que [§22](#22-binaire-screen-select--plan-des-ecrans-ui)
2187 +> proscrit ce filtre.** La règle de §22 vise les **valeurs utilisateur**. Le champ
2188 +> `snippet` n'en est pas une : c'est du HTML produit par le framework, dont chaque
2189 +> valeur interpolée a d'abord été filtrée par liste blanche (`validate`) **puis**
2190 +> échappée en HTML (`render_snippet`). Les deux protections sont cumulatives, et
2191 +> testées séparément.
2192 +>
2193 +> La liste blanche est ce qui garde `onload`, `src` et `integrity` hors de la balise :
2194 +> seuls des noms `data-[a-z0-9-]+` sont acceptés, et les valeurs sont restreintes à
2195 +> `[A-Za-z0-9._:/,-]`. Un administrateur peut donc ajouter des **attributs**, jamais du
2196 +> **comportement**.
2197 +>
2198 +> N'injectez jamais une saisie utilisateur dans ce champ : l'exception vaut pour la
2199 +> valeur produite par `render_snippet()`, pas pour le filtre en général.
2200 +
2201 +### 23.7 CSP
2202 +
2203 +`build()` ajoute l'origine du script à `script-src` **et** `connect-src` — le navigateur
2204 +charge le script depuis cette origine, puis le script y renvoie ses beacons. L'opération
2205 +est idempotente et laisse les autres directives intactes ; une directive absente est
2206 +créée à partir de `default-src`, comme le ferait le navigateur.
2207 +
2208 +L'ajout a lieu **dans `build()`, pas dans le setter** : l'ordre des appels
2209 +`.analytics(..)` et `.security_headers(..)` est donc indifférent, et un
2210 +`.security_headers(..)` tardif ne peut pas faire disparaître silencieusement
2211 +l'autorisation. Voir `SecurityHeadersConfig::allowing_script_origin`
2212 +([§18](#en-tetes-de-securite-securityheadersconfig)).
2213 +
2214 +Sans analytics configuré, la CSP reste **bit-identique** au défaut historique.
2215 +
2216 +### 23.8 Opposition du visiteur (RGPD)
2217 +
2218 +La plupart des fournisseurs se neutralisent entièrement si une clé `localStorage`
2219 +`disable-<namespace>` existe. Le drapeau n'étant lu qu'à l'initialisation du script,
2220 +le changement prend effet **au chargement de page suivant** — ce que dit le libellé
2221 +traduit fourni.
2222 +
2223 +```rust
2224 +let analytics_opt_out =
2225 + AnalyticsOptOutContext::build(&analytics, &i18n_service, locale.as_str()).await;
2226 +```
2227 +
2228 +```html
2229 +{% include "components/analytics_opt_out.html" %}
2230 +```
2231 +
2232 +Le composant ne rend **rien** si aucune clé n'est configurée : une page « vie privée »
2233 +peut l'inclure inconditionnellement. Traductions sous `analytics.opt_out.*` dans les six
2234 +locales livrées.
2235 +
2236 +### 23.9 Limites connues
2237 +
2238 +- Un seul fournisseur à la fois : `AnalyticsConfig` ne porte qu'une URL de script.
2239 +- Pas de bannière de consentement. Un fournisseur qui en attend une se pilote via un
2240 + attribut (`data-consent`), le recueil du consentement restant à la charge de l'application.
2241 +- Pas de collecte côté serveur : le framework émet une balise, il ne mesure rien lui-même.
2242 +- Changer de fournisseur, ou son domaine, demande un redéploiement (§23.2).
A docs/developer-guide/11-audience-measurement.md
+182 -0
@@ -0,0 +1,182 @@
1 +# 11. Mesure d'audience
2 +
3 +Comment brancher un script de suivi sur une application Rustwarden, et comment
4 +migrer un projet qui l'avait câblé en dur.
5 +
6 +Pour la référence exhaustive de l'API, voir
7 +[SERVICES_REFERENCE.md §23](../SERVICES_REFERENCE.md#23-mesure-daudience).
8 +
9 +---
10 +
11 +## Le principe
12 +
13 +Rustwarden ne connaît aucun fournisseur d'analytics. Il modélise le suivi comme
14 +**une URL de script plus des attributs `data-*`**, ce qui suffit à décrire Rybbit,
15 +Plausible, Umami, Matomo-tag et leurs équivalents. Chaque projet décide s'il
16 +mesure son audience, avec quel script, et sous quel identifiant de site.
17 +
18 +Par défaut, **rien n'est activé** : aucune balise rendue, CSP inchangée.
19 +
20 +Trois responsabilités sont réparties ainsi :
21 +
22 +| Quoi | Où | Pourquoi |
23 +|---|---|---|
24 +| L'URL du script | variable d'environnement | Elle alimente la CSP, figée au démarrage |
25 +| Activation, identifiant, attributs | panneau d'administration | Modifiables sans redéploiement |
26 +| L'endroit où la balise apparaît | vos templates | Le framework ne l'injecte pas tout seul |
27 +
28 +---
29 +
30 +## Mise en place
31 +
32 +### 1. Déclarer le script
33 +
34 +```bash
35 +# .env
36 +ANALYTICS_SCRIPT_URL=https://stats.example.org/api/script.js
37 +ANALYTICS_SITE_ID=1d870795f7ae
38 +ANALYTICS_OPT_OUT_STORAGE_KEY=disable-rybbit
39 +ANALYTICS_ENABLED=true
40 +```
41 +
42 +L'attribut portant l'identifiant dépend du fournisseur :
43 +
44 +| Fournisseur | `ANALYTICS_SITE_ID_ATTRIBUTE` |
45 +|---|---|
46 +| Rybbit, Matomo-tag | `data-site-id` (défaut, rien à poser) |
47 +| Umami | `data-website-id` |
48 +| Plausible | `data-domain` |
49 +
50 +### 2. Câbler le builder
51 +
52 +```rust
53 +use rustwarden_core::prelude::*;
54 +
55 +let app = RustwardenApp::builder()
56 + .from_env()
57 + .analytics(AnalyticsConfig::from_env()?)
58 + .build()
59 + .await?;
60 +```
61 +
62 +Ou en dur, sans passer par l'environnement :
63 +
64 +```rust
65 +.analytics(
66 + AnalyticsConfig::default()
67 + .with_script_url("https://stats.example.org/api/script.js")
68 + .with_site_id("1d870795f7ae")
69 + .with_opt_out_storage_key("disable-rybbit")
70 + .enabled(true),
71 +)
72 +```
73 +
74 +**Vous n'avez rien à faire pour la CSP.** `build()` ajoute l'origine du script à
75 +`script-src` et `connect-src`. L'ordre des appels `.analytics(..)` et
76 +`.security_headers(..)` est indifférent.
77 +
78 +### 3. Poser la balise dans vos pages
79 +
80 +Le framework ne touche pas à votre layout : c'est à vous d'appeler le helper, une
81 +fois, dans votre template de base.
82 +
83 +```rust
84 +// Handler
85 +pub async fn ma_page(
86 + analytics: AnalyticsContext,
87 + // ..
88 +) -> Result<impl IntoResponse, AppError> {
89 + Ok(MaPageTemplate { analytics, /* .. */ })
90 +}
91 +```
92 +
93 +```html
94 +<!-- base.html, avant </head> -->
95 +{% if analytics.is_enabled() %}{{ analytics.snippet|safe }}{% endif %}
96 +```
97 +
98 +Le `|safe` est légitime ici, et seulement ici : la valeur est produite par le
99 +framework après validation par liste blanche **et** échappement HTML. N'y mettez
100 +jamais de saisie utilisateur.
101 +
102 +### 4. Offrir l'opposition au visiteur
103 +
104 +```rust
105 +let analytics_opt_out =
106 + AnalyticsOptOutContext::build(&analytics, &i18n_service, locale.as_str()).await;
107 +```
108 +
109 +```html
110 +<!-- Dans votre page « vie privée » -->
111 +{% include "components/analytics_opt_out.html" %}
112 +```
113 +
114 +Le composant ne rend rien si aucune clé d'opposition n'est configurée : vous
115 +pouvez l'inclure sans condition. Le choix du visiteur est stocké dans son
116 +navigateur et prend effet **au chargement de page suivant**, parce que la plupart
117 +des scripts ne lisent ce drapeau qu'à leur initialisation.
118 +
119 +### 5. Piloter au quotidien
120 +
121 +`/admin/settings` expose une section « Mesure d'audience » : interrupteur,
122 +identifiant de site, attributs additionnels. Les changements prennent effet
123 +**immédiatement**, sans redémarrage.
124 +
125 +L'URL du script y apparaît en lecture seule. Ce n'est pas un oubli : la CSP est
126 +construite une fois au démarrage, donc une URL modifiable à chaud produirait un
127 +script bloqué par le navigateur. Cela évite aussi qu'un compte d'administration
128 +compromis puisse élargir la CSP vers un domaine arbitraire.
129 +
130 +---
131 +
132 +## Migrer un projet qui l'avait en dur
133 +
134 +Exemple type, tel qu'on le trouve dans un projet consommateur avant migration :
135 +un partiel `pm_analytics.html` inclus dans une vingtaine de templates, un
136 +composant d'opposition avec ses traductions écrites en ligne, et un middleware
137 +qui réécrit la CSP entière pour autoriser le domaine du tracker.
138 +
139 +### Ce qui disparaît
140 +
141 +| À supprimer | Remplacé par |
142 +|---|---|
143 +| Le partiel contenant la balise en dur | `{{ analytics.snippet\|safe }}` dans le layout |
144 +| Les `{% include %}` répétés page par page | Un seul appel dans le template de base |
145 +| Le middleware qui surcharge la CSP | `build()`, automatiquement |
146 +| La clé `localStorage` écrite en dur | `ANALYTICS_OPT_OUT_STORAGE_KEY` |
147 +| Les traductions inline du composant d'opposition | `analytics.opt_out.*` |
148 +
149 +### Marche à suivre
150 +
151 +1. **Relever la configuration existante** dans le partiel : URL du script,
152 + identifiant de site, attributs supplémentaires (`data-consent`…).
153 +2. **Les porter en variables d'environnement**, selon l'étape 1 ci-dessus.
154 +3. **Câbler `.analytics(..)`** sur le builder.
155 +4. **Supprimer le partiel et tous ses `{% include %}`**, et poser l'appel unique
156 + dans le layout. C'est l'étape qui supprime le risque d'oubli sur une page.
157 +5. **Retirer la surcharge de CSP.** Attention : si ce middleware ajoutait *autre
158 + chose* que le domaine du tracker — typiquement `'unsafe-eval'` pour Alpine.js —
159 + ne le supprimez pas aveuglément. Portez ce qui reste dans un
160 + `SecurityHeadersConfig` explicite, et laissez `build()` s'occuper du seul
161 + domaine d'analytics.
162 +6. **Remplacer le composant d'opposition** par `components/analytics_opt_out.html`.
163 +7. **Vérifier**, application lancée :
164 +
165 +```bash
166 +curl -sI http://localhost:3000/login | grep -i content-security-policy
167 +```
168 +
169 +L'origine doit apparaître dans `script-src` **et** `connect-src`. Puis, dans le
170 +navigateur : la balise est présente, l'interrupteur d'`/admin/settings` la fait
171 +disparaître sans redémarrage, et le bouton d'opposition empêche le script de
172 +s'initialiser au rechargement suivant.
173 +
174 +---
175 +
176 +## Limites
177 +
178 +- Un seul fournisseur à la fois.
179 +- Pas de bannière de consentement : un fournisseur qui en attend une se pilote par
180 + un attribut (`data-consent`), le recueil restant à la charge de l'application.
181 +- Pas de collecte côté serveur : le framework émet une balise, il ne mesure rien.
182 +- Changer de domaine de collecte demande un redéploiement.
M docs/developer-guide/README.md
+1 -0
@@ -24,6 +24,7 @@ Le public cible est double. D'un côté, les **développeurs consommateurs** qui
24 24 | [08-fediverse-integration.md](08-fediverse-integration.md) | Branchement de `rustwarden-fediverse` via traits, ActivityPub headless |
25 25 | [09-hooks-and-events.md](09-hooks-and-events.md) | Trait `RustwardenHooks`, liste des événements, exemple d'implémentation |
26 26 | [10-testing-and-observability.md](10-testing-and-observability.md) | Stratégie de test (538 tests, pyramide, e2e fédération), état de l'observabilité et recommandations V2 |
27 +| [11-audience-measurement.md](11-audience-measurement.md) | Brancher un script de suivi générique (URL + attributs `data-*`), CSP automatique, opposition du visiteur, migration d'un projet qui l'avait en dur |
27 28
28 29 ---
29 30
A docs/plans/analytics-tracking-generique.md
+291 -0
@@ -0,0 +1,291 @@
1 +# Mesure d'audience générique pour Rustwarden
2 +
3 +## Contexte
4 +
5 +`rs_pasterman` embarque son suivi d'audience **en dur** : le fichier
6 +[templates/components/pm_analytics.html](../../rs_pasterman/templates/components/pm_analytics.html)
7 +contient un `<script src="https://stats.ebii.fr/api/script.js" data-site-id="1d870795f7ae" …>`
8 +inclus manuellement via `{% include %}` dans une vingtaine de templates, un composant
9 +d'opposition `pm_analytics_optout.html` qui code en dur la clé localStorage `disable-rybbit`
10 +avec ses quatre traductions inline, et une surcharge complète de la CSP du framework
11 +dans `src/middleware.rs` pour autoriser le domaine du tracker.
12 +
13 +Rien de tout cela n'est réutilisable : changer de fournisseur, d'identifiant de site, ou
14 +simplement désactiver le suivi demande d'éditer du code et de redéployer. Chaque projet
15 +bâti sur Rustwarden devrait au contraire pouvoir décider **s'il track, avec quel script,
16 +et sous quel identifiant de site**, sans forker le framework.
17 +
18 +**Résultat visé** : une `AnalyticsConfig` de premier ordre dans `rustwarden-core`, qui rend
19 +le snippet à la demande, étend automatiquement la CSP pour autoriser l'origine du script,
20 +et se pilote depuis le panneau d'administration — tout en restant utilisable en headless.
21 +
22 +## Décisions structurantes
23 +
24 +| Sujet | Décision |
25 +|---|---|
26 +| Modèle de fournisseur | Générique : URL de script + attributs `data-*`. Aucun code spécifique par fournisseur (couvre Rybbit, Plausible, Umami, Matomo-tag…) |
27 +| Source de configuration | Env/builder = valeur par défaut, `app_settings` (DB) = surcharge |
28 +| **Origine du script** | **Env/builder uniquement, figée au démarrage** — voir ci-dessous |
29 +| CSP | Étendue automatiquement dans `build()` à partir de l'origine configurée |
30 +| Injection | Helper explicite appelé par le template. Pas d'inclusion automatique dans le layout |
31 +| Opt-out | Clé localStorage configurable, généralisation du composant de `rs_pasterman` |
32 +| Découpage | `rustwarden-core` = config + rendu + CSP ; `rustwarden-ui` = écran d'admin + composant d'opposition |
33 +
34 +### Pourquoi l'origine du script reste statique
35 +
36 +`security_headers_middleware` capture sa configuration **une seule fois**, dans un `Arc`
37 +construit au `build()` et placé *hors* de toute couche `Extension`
38 +([app.rs:717-733](crates/rustwarden-core/src/app.rs#L717-L733), contrainte assumée de la RFC 0039).
39 +Une CSP pilotée depuis la base serait donc désynchronisée jusqu'au prochain redémarrage.
40 +
41 +Le découpage retenu résout la tension sans toucher à la RFC 0039 : l'**URL du script** —
42 +seule donnée qui alimente la CSP — vient de l'environnement ou du builder et ne change
43 +qu'au redéploiement ; l'**activation, l'identifiant de site et les attributs** vivent en base
44 +et se modifient à chaud. Bénéfice de sécurité : un compte d'administration compromis ne peut
45 +pas élargir la CSP vers un domaine arbitraire, ce qui serait une primitive d'exfiltration.
46 +
47 +## Implémentation
48 +
49 +### 1. `crates/rustwarden-core/src/config/analytics.rs` (nouveau)
50 +
51 +Suit le modèle de [`security_headers.rs`](crates/rustwarden-core/src/config/security_headers.rs) :
52 +struct simple, `Default` conservateur (suivi désactivé), helpers `with_*` additifs, tests en fin
53 +de fichier. Déclarer le module dans [`config/mod.rs`](crates/rustwarden-core/src/config/mod.rs).
54 +
55 +```rust
56 +pub struct AnalyticsConfig {
57 + pub enabled: bool, // défaut: false
58 + pub script_url: Option<String>, // env: ANALYTICS_SCRIPT_URL — alimente la CSP
59 + pub site_id: Option<String>, // env: ANALYTICS_SITE_ID
60 + pub site_id_attribute: String, // défaut "data-site-id" ; Plausible → "data-domain"
61 + pub attributes: BTreeMap<String, String>, // data-* additionnels (ex. data-consent=opt-in)
62 + pub opt_out_storage_key: Option<String>, // ex. "disable-rybbit"
63 + pub defer: bool, // défaut: true
64 +}
65 +```
66 +
67 +Exporter `AnalyticsConfig` depuis le prelude ([lib.rs:50](crates/rustwarden-core/src/lib.rs#L50)),
68 +aux côtés de `SecurityHeadersConfig`.
69 +
70 +Méthodes :
71 +
72 +- `from_env() -> Result<Self, AnalyticsConfigError>` — mêmes conventions de nommage que les
73 + autres configs ; absence de variables ⇒ `Default` (désactivé), jamais d'erreur.
74 +- `validate()` — **porte de sécurité principale** :
75 + - `script_url` doit être une URL absolue en `https://` (tolérer `http://localhost` en
76 + `debug_mode` uniquement), sans `..`, sans caractère de contrôle ;
77 + - `site_id` et valeurs d'attributs restreints à une liste blanche `[A-Za-z0-9._:/-]` ;
78 + - noms d'attributs restreints à `data-[a-z0-9-]+` — interdit explicitement `src`, `onload`,
79 + `integrity` et tout `on*` ;
80 + - `opt_out_storage_key` restreinte à `[A-Za-z0-9._-]`.
81 +- `script_origin() -> Option<String>` — extrait `scheme://host[:port]`, consommé par la CSP.
82 +- `render_snippet() -> Option<String>` — retourne `None` si désactivé ou sans URL. Sinon
83 + construit la balise en **échappant en HTML** chaque valeur interpolée (défense en profondeur
84 + par-dessus la validation par liste blanche). Un `site_id` valant `"><script>alert(1)</script>`
85 + doit ressortir inerte — c'est le cas de test à écrire en premier.
86 +
87 +Pas de dépendance nouvelle : parsing d'URL à la main (`split_once`) ou via `url` s'il est déjà
88 +dans l'arbre de dépendances — à vérifier avec `cargo tree -p rustwarden-core -i url`.
89 +
90 +### 2. Résolution env + DB, sans requête par page
91 +
92 +La surcharge DB réutilise la table et le service existants — **aucune migration nécessaire**
93 +([`app_settings_service.rs`](crates/rustwarden-core/src/services/app_settings_service.rs), qui
94 +fournit déjà `get_setting`, `get_bool`, `set_setting`). Clés : `analytics_enabled`,
95 +`analytics_site_id`, `analytics_attributes` (JSON). Ajouter leurs valeurs par défaut à
96 +`initialize_default_settings`.
97 +
98 +Interroger la base à chaque rendu de page serait inacceptable. Introduire dans
99 +`analytics.rs` un état partagé, injecté en `Extension` comme `i18n_service` :
100 +
101 +```rust
102 +pub struct AnalyticsState {
103 + config: AnalyticsConfig, // immuable : porte script_url
104 + rendered: RwLock<Arc<Option<String>>>, // snippet pré-rendu, y compris l'état désactivé
105 +}
106 +impl AnalyticsState {
107 + pub async fn load(db: &DatabaseConnection, cfg: AnalyticsConfig) -> Result<Arc<Self>, AppError>;
108 + pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError>; // après sauvegarde admin
109 + pub fn snippet(&self) -> Arc<Option<String>>; // clone d'Arc, pas de I/O
110 +}
111 +```
112 +
113 +Le snippet est rendu **au chargement et au reload**, jamais pendant une requête : `snippet()`
114 +n'est qu'un clone d'`Arc`. Utiliser `std::sync::RwLock` (verrou jamais tenu à travers un `await`),
115 +ce qui évite d'ajouter `arc-swap`.
116 +
117 +### 3. Extension automatique de la CSP
118 +
119 +Dans [`security_headers.rs`](crates/rustwarden-core/src/config/security_headers.rs), ajouter
120 +un helper additif dans l'esprit de `with_same_origin_isolation` :
121 +
122 +```rust
123 +pub fn allowing_script_origin(self, origin: &str) -> Self
124 +```
125 +
126 +Il analyse la politique existante directive par directive (découpage sur `;`) et ajoute
127 +l'origine à `script-src` **et** `connect-src` (le tracker charge un script *et* émet des
128 +beacons). Si une directive est absente, la créer en reprenant les sources de `default-src`.
129 +Opération idempotente : une origine déjà présente n'est pas dupliquée.
130 +
131 +Câblage dans `RustwardenBuilder` :
132 +
133 +- nouveau setter `analytics(cfg: AnalyticsConfig)`, à côté de
134 + [`security_headers`](crates/rustwarden-core/src/app.rs#L261) ;
135 +- l'extension CSP est appliquée **dans `build()`**, pas dans le setter — sinon un appel à
136 + `.security_headers(…)` postérieur à `.analytics(…)` écraserait silencieusement l'autorisation ;
137 +- `build()` construit l'`AnalyticsState` et l'ajoute via `.layer(axum::Extension(analytics_state))`,
138 + aux côtés des extensions existantes ([app.rs:744-747](crates/rustwarden-core/src/app.rs#L744-L747)).
139 +
140 +Le test de non-régression `default_csp_matches_the_historical_literal` doit rester vert :
141 +sans analytics configuré, la CSP par défaut est inchangée au caractère près.
142 +
143 +### 4. Helper de rendu côté consommateur
144 +
145 +Un extracteur Axum (`FromRequestParts`) lit l'`Extension<Arc<AnalyticsState>>` et expose le
146 +snippet, que le handler place dans son struct de template. Côté Askama :
147 +`{{ analytics_snippet|safe }}`. Documenter explicitement en rustdoc que `|safe` est légitime
148 +ici parce que la valeur est produite par `render_snippet()` après validation et échappement,
149 +et **jamais** par une entrée utilisateur directe.
150 +
151 +Ce chemin fonctionne aussi en mode headless : un consommateur sans `rustwarden-ui` appelle le
152 +même extracteur depuis ses propres templates. Aucune modification de
153 +[`base.html`](crates/rustwarden-ui/templates/base.html) — l'inclusion reste un choix du projet
154 +consommateur, conformément à l'option « helper explicite » retenue.
155 +
156 +### 5. `rustwarden-ui` — écran d'administration
157 +
158 +Étendre l'écran existant plutôt que d'en créer un
159 +([admin_handler.rs](crates/rustwarden-ui/src/handlers/admin_handler.rs)) :
160 +
161 +- ajouter les champs à `AdminSettingsFormData` et les entrées correspondantes au vecteur
162 + `settings_to_save` de [`update_app_settings`](crates/rustwarden-ui/src/handlers/admin_handler.rs#L2039) ;
163 +- ajouter les libellés traduits à `AdminSettingsTemplate` (le struct porte déjà ses traductions
164 + sous forme de `String`, [admin_handler.rs:1493-1512](crates/rustwarden-ui/src/handlers/admin_handler.rs#L1493-L1512)) ;
165 +- nouvelle section « Mesure d'audience » dans `templates/admin/settings.html` : interrupteur
166 + d'activation, identifiant de site, attributs additionnels ;
167 +- l'URL du script est affichée en **lecture seule**, avec la mention qu'elle se configure par
168 + variable d'environnement — matérialise la décision de sécurité pour l'administrateur ;
169 +- après sauvegarde, appeler `AnalyticsState::reload(&db)` pour que le changement prenne effet
170 + immédiatement ;
171 +- la validation de `AnalyticsConfig` est rejouée sur les valeurs soumises, avec un message
172 + d'erreur exploitable : ne jamais écrire en base une valeur qui ne passerait pas `validate()`.
173 +
174 +### 6. `rustwarden-ui` — composant d'opposition
175 +
176 +`templates/components/analytics_opt_out.html`, généralisation de
177 +[pm_analytics_optout.html](../../rs_pasterman/templates/components/pm_analytics_optout.html) :
178 +même mécanique Alpine (lecture/écriture de la clé localStorage, `try/catch` pour les navigateurs
179 +en mode restreint), à deux différences près — la clé vient de la configuration au lieu d'être
180 +codée en dur, et les libellés passent par l'i18n du framework plutôt que par une cascade
181 +`{% if locale == "en" %}` dans le template.
182 +
183 +Alimenter un sous-contexte `analytics_opt_out: AnalyticsOptOutContext` (clé + libellés traduits)
184 +porté par le template parent, et ajouter les clés dans `locales/{fr,en,de,es}.yaml`.
185 +
186 +## Tests
187 +
188 +À écrire en même temps que le code, les cas de sécurité en premier.
189 +
190 +**Unitaires `analytics.rs`** — `from_env` (absence de variables ⇒ désactivé) ; `validate`
191 +rejette une URL `http://` hors debug, une URL relative, un nom d'attribut `onload`/`src`, un
192 +`site_id` hors liste blanche ; `render_snippet` retourne `None` si désactivé et échappe les
193 +métacaractères HTML ; `script_origin` sur URL avec port, chemin et sous-domaine.
194 +
195 +**Unitaires CSP** — `allowing_script_origin` ajoute à `script-src` *et* `connect-src`, préserve
196 +les autres directives, est idempotente, et crée la directive manquante depuis `default-src`.
197 +Vérifier que les tests RFC 0039 existants restent verts.
198 +
199 +**Intégration** — au niveau routeur, avec le pattern de `security_headers_middleware_tests`
200 +([app.rs:1087+](crates/rustwarden-core/src/app.rs#L1087)) : analytics désactivé ⇒ en-tête CSP
201 +identique au défaut historique ; analytics activé ⇒ l'origine apparaît dans les deux directives.
202 +
203 +**Bout en bout manuel** :
204 +
205 +```bash
206 +cargo clippy --all-targets # objectif zéro warning (règle projet)
207 +cargo test -p rustwarden-core # borner le parallélisme si la machine peine
208 +cargo test -p rustwarden-ui
209 +ANALYTICS_SCRIPT_URL=https://stats.example.org/api/script.js \
210 +ANALYTICS_SITE_ID=abc123 ANALYTICS_ENABLED=true cargo run -p rustwarden-app
211 +```
212 +
213 +Puis vérifier : `curl -sI localhost:3000/login | grep -i content-security-policy` contient bien
214 +l'origine dans `script-src` et `connect-src` ; la balise `<script>` est présente dans le HTML ;
215 +l'interrupteur d'`/admin/settings` la fait disparaître **sans redémarrage** ; le bouton
216 +d'opposition écrit la clé localStorage et le script ne s'initialise plus au rechargement.
217 +
218 +Ne pas lancer `--all-features` (la feature `redis-session` ne compile pas), ni la suite complète
219 +du workspace sans borner `--test-threads`.
220 +
221 +## Documentation
222 +
223 +### Rustdoc
224 +
225 +**En anglais** sur toute l'API publique (`missing_docs = warn` au niveau workspace), avec un
226 +exemple compilable en tête de `analytics.rs` sur le modèle de `security_headers.rs`.
227 +
228 +### `docs/SERVICES_REFERENCE.md` — trois interventions
229 +
230 +Ce document de 2026 lignes est la référence des projets consommateurs (PasterMan, GitRust).
231 +C'est de la **référence** au sens Diátaxis : descriptif, neutre, structuré comme le code —
232 +tables `Méthode | Signature | Description`, en-tête `**Module**` / `**Export**` / `**RFC**`,
233 +aucun contenu tutoriel. Suivre le style des sections voisines, y compris leur usage des
234 +accents dans les titres, plutôt que d'imposer une convention nouvelle.
235 +
236 +**a. Nouvelle section « 23. Mesure d'audience »**, après
237 +[§22 `screen-select`](docs/SERVICES_REFERENCE.md#L1962), plus son entrée au sommaire
238 +([lignes 25-46](docs/SERVICES_REFERENCE.md#L25-L46)). Contenu, calqué sur la forme de
239 +[§18 En-tetes de securite](docs/SERVICES_REFERENCE.md#L1342) :
240 +
241 +- en-tête `**Module**` : `rustwarden_core::config::analytics` — `**Export**` : prelude ;
242 +- le bloc `struct AnalyticsConfig` tel qu'exposé ;
243 +- table des éléments : `default()`, `from_env`, `validate`, `script_origin`,
244 + `render_snippet`, `AnalyticsState::{load, reload, snippet}`, `RustwardenBuilder::analytics` ;
245 +- table des variables d'environnement et des clés `app_settings`, avec une colonne indiquant
246 + pour chacune si elle se pilote par l'environnement ou depuis la base ;
247 +- **encadré sur la répartition env/DB** : pourquoi `script_url` n'est pas modifiable depuis
248 + l'administration (CSP figée au démarrage, et refus de donner à un compte admin compromis une
249 + primitive d'élargissement de la CSP) ;
250 +- table de correspondance pour les fournisseurs courants — Rybbit `data-site-id`, Plausible
251 + `data-domain`, Umami `data-website-id` — qui concrétise le modèle générique sans le
252 + spécialiser ;
253 +- le contrat de rendu : `render_snippet()` produit du HTML déjà échappé, d'où le `|safe` en
254 + Askama, et les valeurs admissibles pour `site_id` et les attributs ;
255 +- la clé localStorage d'opposition et son comportement (lue à l'initialisation du script, donc
256 + effective au chargement de page suivant).
257 +
258 +**b. §10 Configuration applicative** — ajouter `analytics_enabled`, `analytics_site_id` et
259 +`analytics_attributes` à la table des paramètres par défaut
260 +([lignes 856-865](docs/SERVICES_REFERENCE.md#L856-L865)), avec un renvoi vers §23.
261 +
262 +**c. §18 En-tetes de securite** — ajouter `allowing_script_origin` à la table des éléments
263 +([ligne 1361](docs/SERVICES_REFERENCE.md#L1361)) et préciser que l'extension est appliquée
264 +**dans `build()`** : l'ordre des appels `.analytics(..)` / `.security_headers(..)` au builder
265 +est donc indifférent. Préserver l'encadré « le défaut est bit-identique à 1.0.1 » en indiquant
266 +qu'un consommateur sans analytics configuré ne voit toujours aucun changement d'en-tête.
267 +
268 +Pas de bandeau ⚠️ en tête de document : l'ajout est purement additif, sans migration SeaORM ni
269 +changement de comportement pour les consommateurs existants. Nouvelle API publique ⇒ version
270 +**mineure**, cohérente avec le traitement de la RFC 0039 en 1.1.0.
271 +
272 +### Autres documents
273 +
274 +Entrée `CHANGELOG` (section `Added`). Si l'ampleur le justifie au moment d'implémenter, une RFC
275 +dans `docs/rfcs/` suivant `RUSTWARDEN_EVOLUTION_PROCESS.md`, référencée depuis §23 comme le font
276 +les autres sections. La marche à suivre pour migrer `rs_pasterman` vers le mécanisme générique
277 +est un guide d'usage, pas de la référence : elle va dans `docs/developer-guide/`, pas dans
278 +`SERVICES_REFERENCE.md`.
279 +
280 +## Hors périmètre
281 +
282 +Pas d'inclusion automatique dans `base.html`, pas de CSP dynamique, pas de collecte d'audience
283 +côté serveur, pas d'enum de fournisseurs prédéfinis, pas de bannière de consentement (le
284 +`data-consent="opt-in"` de `rs_pasterman` est couvert par la map d'attributs générique).
285 +La migration effective de `rs_pasterman` est un travail distinct, dans son propre dépôt.
286 +
287 +## Note de rangement
288 +
289 +Ce plan vit sous `~/.claude/plans/` uniquement pendant la rédaction. Dès la sortie du mode plan,
290 +avant toute autre action : le copier vers `docs/plans/analytics-tracking-generique.md`, supprimer
291 +l'original, et ne plus référencer que la copie (règle `CLAUDE.md` du projet).