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("&"), | |
| 472 | + '<' => out.push_str("<"), | |
| 473 | + '>' => out.push_str(">"), | |
| 474 | + '"' => out.push_str("""), | |
| 475 | + '\'' => out.push_str("'"), | |
| 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(""><script>")); | |
| 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 `'`, 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). |
GitRust