analytics.rs 1026 lignes · 37111 octets
//! Audience-measurement (analytics) configuration.
//!
//! Provider-agnostic support for a third-party tracking script, modelled as a
//! script URL plus a set of `data-*` attributes. That shape covers Rybbit,
//! Plausible, Umami, Matomo-tag and similar vendors without shipping any
//! vendor-specific code: only the attribute names differ between them.
//!
//! # Split between environment and database
//!
//! The **script URL** comes from the environment or the builder and is frozen
//! at startup, because it feeds the `Content-Security-Policy` emitted by
//! `security_headers_middleware`, which captures its configuration once when
//! the router is built (see `RustwardenBuilder::build`). A URL editable at
//! runtime would leave the CSP out of sync until the next restart.
//!
//! Everything else — whether tracking is on, the site identifier, the extra
//! attributes — may be overridden from the `app_settings` table and takes
//! effect without a restart, through [`AnalyticsState::reload`].
//!
//! Keeping the origin out of the database is also a security property: a
//! compromised administrator account cannot widen the CSP towards an
//! arbitrary domain, which would be an exfiltration primitive.
//!
//! # Example
//!
//! ```
//! use rustwarden_core::config::analytics::AnalyticsConfig;
//!
//! let cfg = AnalyticsConfig::default()
//!     .with_script_url("https://stats.example.org/api/script.js")
//!     .with_site_id("abc123")
//!     .enabled(true);
//!
//! assert!(cfg.validate().is_ok());
//! assert_eq!(cfg.script_origin().as_deref(), Some("https://stats.example.org"));
//!
//! let snippet = cfg.render_snippet().expect("enabled config renders");
//! assert!(snippet.contains(r#"data-site-id="abc123""#));
//! ```

use crate::error::AppError;
use crate::services::app_settings_service::AppSettingsService;
use sea_orm::DatabaseConnection;
use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};

/// Attribute carrying the site identifier for most vendors (Rybbit, Umami
/// uses `data-website-id`, Plausible uses `data-domain`).
pub const DEFAULT_SITE_ID_ATTRIBUTE: &str = "data-site-id";

/// `app_settings` key toggling audience measurement at runtime.
pub const SETTING_ANALYTICS_ENABLED: &str = "analytics_enabled";
/// `app_settings` key holding the site identifier at runtime.
pub const SETTING_ANALYTICS_SITE_ID: &str = "analytics_site_id";
/// `app_settings` key holding extra `data-*` attributes, as a JSON object.
pub const SETTING_ANALYTICS_ATTRIBUTES: &str = "analytics_attributes";

/// Reasons an [`AnalyticsConfig`] can be rejected.
///
/// Validation is the primary security gate of this module: values reaching it
/// may originate from the administration panel, and end up inside rendered
/// HTML.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AnalyticsConfigError {
    /// Tracking is enabled but no script URL was supplied.
    #[error("analytics is enabled but no script URL is configured")]
    MissingScriptUrl,
    /// The script URL is not an absolute `https://` URL (or `http://` on a
    /// loopback host).
    #[error("invalid analytics script URL: {0}")]
    InvalidScriptUrl(String),
    /// An attribute name is not a plain `data-*` name.
    #[error("invalid analytics attribute name `{0}`: expected `data-` followed by [a-z0-9-]")]
    InvalidAttributeName(String),
    /// An attribute value, or the site identifier, contains a character
    /// outside the allow-list.
    #[error("invalid value for analytics attribute `{name}`: {reason}")]
    InvalidAttributeValue {
        /// Attribute the offending value belongs to.
        name: String,
        /// Why the value was rejected.
        reason: String,
    },
    /// The same attribute is declared twice (site-id attribute and extras).
    #[error("analytics attribute `{0}` is declared both as site-id attribute and as an extra")]
    DuplicateAttribute(String),
    /// The opt-out localStorage key contains unsupported characters.
    #[error("invalid opt-out storage key `{0}`: expected [A-Za-z0-9._-]")]
    InvalidOptOutKey(String),
    /// The JSON payload of extra attributes could not be parsed.
    #[error("invalid analytics attributes JSON: {0}")]
    InvalidAttributesJson(String),
}

/// Generic audience-measurement configuration.
///
/// [`Default`] is deliberately conservative: tracking is **off** and nothing
/// is rendered, so a consumer that never configures analytics observes no
/// change whatsoever — neither in the page nor in the CSP.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AnalyticsConfig {
    /// Whether the tracking script is rendered. Default: `false`.
    pub enabled: bool,
    /// Absolute URL of the tracking script. Feeds the CSP, therefore only
    /// settable from the environment or the builder.
    pub script_url: Option<String>,
    /// Site identifier handed to the vendor.
    pub site_id: Option<String>,
    /// Attribute carrying [`Self::site_id`]. Default:
    /// [`DEFAULT_SITE_ID_ATTRIBUTE`].
    pub site_id_attribute: String,
    /// Extra `data-*` attributes, e.g. `data-consent` → `opt-in`. Ordered, so
    /// the rendered snippet is deterministic.
    pub attributes: BTreeMap<String, String>,
    /// `localStorage` key a visitor can set to opt out. Vendor-specific, e.g.
    /// `disable-rybbit`. `None` disables the opt-out component.
    pub opt_out_storage_key: Option<String>,
    /// Whether to render the `defer` attribute. Default: `true`.
    pub defer: bool,
}

impl Default for AnalyticsConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            script_url: None,
            site_id: None,
            site_id_attribute: DEFAULT_SITE_ID_ATTRIBUTE.to_string(),
            attributes: BTreeMap::new(),
            opt_out_storage_key: None,
            defer: true,
        }
    }
}

impl AnalyticsConfig {
    /// Load the configuration from the environment.
    ///
    /// Reads `ANALYTICS_ENABLED`, `ANALYTICS_SCRIPT_URL`, `ANALYTICS_SITE_ID`,
    /// `ANALYTICS_SITE_ID_ATTRIBUTE`, `ANALYTICS_ATTRIBUTES` (a JSON object),
    /// `ANALYTICS_OPT_OUT_STORAGE_KEY` and `ANALYTICS_DEFER`.
    ///
    /// With none of them set, returns [`Default`] — tracking disabled.
    ///
    /// # Errors
    ///
    /// Returns [`AnalyticsConfigError`] if the resulting configuration does
    /// not pass [`Self::validate`], or if `ANALYTICS_ATTRIBUTES` is not a
    /// JSON object of strings.
    pub fn from_env() -> Result<Self, AnalyticsConfigError> {
        let mut cfg = Self::default();

        if let Ok(raw) = std::env::var("ANALYTICS_ENABLED") {
            cfg.enabled = matches!(
                raw.trim().to_ascii_lowercase().as_str(),
                "true" | "1" | "on"
            );
        }
        if let Ok(url) = std::env::var("ANALYTICS_SCRIPT_URL") {
            let url = url.trim().to_string();
            if !url.is_empty() {
                cfg.script_url = Some(url);
            }
        }
        if let Ok(site_id) = std::env::var("ANALYTICS_SITE_ID") {
            let site_id = site_id.trim().to_string();
            if !site_id.is_empty() {
                cfg.site_id = Some(site_id);
            }
        }
        if let Ok(attr) = std::env::var("ANALYTICS_SITE_ID_ATTRIBUTE") {
            let attr = attr.trim().to_string();
            if !attr.is_empty() {
                cfg.site_id_attribute = attr;
            }
        }
        if let Ok(raw) = std::env::var("ANALYTICS_ATTRIBUTES") {
            if !raw.trim().is_empty() {
                cfg.attributes = parse_attributes_json(&raw)?;
            }
        }
        if let Ok(key) = std::env::var("ANALYTICS_OPT_OUT_STORAGE_KEY") {
            let key = key.trim().to_string();
            if !key.is_empty() {
                cfg.opt_out_storage_key = Some(key);
            }
        }
        if let Ok(raw) = std::env::var("ANALYTICS_DEFER") {
            cfg.defer = !matches!(
                raw.trim().to_ascii_lowercase().as_str(),
                "false" | "0" | "off"
            );
        }

        cfg.validate()?;
        Ok(cfg)
    }

    /// Builder-style setter for [`Self::script_url`].
    pub fn with_script_url(mut self, url: impl Into<String>) -> Self {
        self.script_url = Some(url.into());
        self
    }

    /// Builder-style setter for [`Self::site_id`].
    pub fn with_site_id(mut self, site_id: impl Into<String>) -> Self {
        self.site_id = Some(site_id.into());
        self
    }

    /// Builder-style setter for [`Self::site_id_attribute`], e.g.
    /// `data-domain` for Plausible or `data-website-id` for Umami.
    pub fn with_site_id_attribute(mut self, attribute: impl Into<String>) -> Self {
        self.site_id_attribute = attribute.into();
        self
    }

    /// Add one extra `data-*` attribute.
    pub fn with_attribute(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        self.attributes.insert(name.into(), value.into());
        self
    }

    /// Replace the extra attributes from a JSON object of string values.
    ///
    /// This is the format stored in `app_settings` and submitted by the
    /// administration form.
    ///
    /// # Errors
    ///
    /// Returns [`AnalyticsConfigError::InvalidAttributesJson`] if the payload
    /// is not a JSON object whose values are all strings.
    pub fn with_attributes_json(mut self, raw: &str) -> Result<Self, AnalyticsConfigError> {
        self.attributes = parse_attributes_json(raw)?;
        Ok(self)
    }

    /// Builder-style setter for [`Self::opt_out_storage_key`].
    pub fn with_opt_out_storage_key(mut self, key: impl Into<String>) -> Self {
        self.opt_out_storage_key = Some(key.into());
        self
    }

    /// Builder-style setter for [`Self::enabled`].
    pub fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }

    /// Validate the configuration.
    ///
    /// A disabled configuration with no script URL is always valid: that is
    /// the default, and it renders nothing.
    ///
    /// # Errors
    ///
    /// See [`AnalyticsConfigError`].
    pub fn validate(&self) -> Result<(), AnalyticsConfigError> {
        if let Some(url) = &self.script_url {
            validate_script_url(url)?;
        } else if self.enabled {
            return Err(AnalyticsConfigError::MissingScriptUrl);
        }

        validate_attribute_name(&self.site_id_attribute)?;
        if let Some(site_id) = &self.site_id {
            validate_attribute_value(&self.site_id_attribute, site_id)?;
        }

        for (name, value) in &self.attributes {
            if name == &self.site_id_attribute {
                return Err(AnalyticsConfigError::DuplicateAttribute(name.clone()));
            }
            validate_attribute_name(name)?;
            validate_attribute_value(name, value)?;
        }

        if let Some(key) = &self.opt_out_storage_key {
            if key.is_empty() || !key.chars().all(is_storage_key_char) {
                return Err(AnalyticsConfigError::InvalidOptOutKey(key.clone()));
            }
        }

        Ok(())
    }

    /// Origin (`scheme://host[:port]`) of the tracking script, for the CSP.
    ///
    /// Returns `None` when no script URL is configured, whether or not
    /// tracking is enabled — the origin is what the CSP must allow, and it is
    /// frozen at startup regardless of the runtime toggle.
    pub fn script_origin(&self) -> Option<String> {
        self.script_url.as_deref().and_then(script_origin_of)
    }

    /// Render the `<script>` tag, or `None` when tracking is disabled or no
    /// script URL is configured.
    ///
    /// Every interpolated value is HTML-escaped, on top of the allow-list
    /// enforced by [`Self::validate`]. The returned string is therefore safe
    /// to emit through Askama's `|safe` filter.
    pub fn render_snippet(&self) -> Option<String> {
        if !self.enabled {
            return None;
        }
        let url = self.script_url.as_deref()?;

        let mut tag = String::from("<script src=\"");
        tag.push_str(&escape_html_attribute(url));
        tag.push('"');

        if let Some(site_id) = &self.site_id {
            tag.push(' ');
            tag.push_str(&escape_html_attribute(&self.site_id_attribute));
            tag.push_str("=\"");
            tag.push_str(&escape_html_attribute(site_id));
            tag.push('"');
        }

        for (name, value) in &self.attributes {
            tag.push(' ');
            tag.push_str(&escape_html_attribute(name));
            tag.push_str("=\"");
            tag.push_str(&escape_html_attribute(value));
            tag.push('"');
        }

        if self.defer {
            tag.push_str(" defer");
        }
        tag.push_str("></script>");
        Some(tag)
    }
}

/// Parse a JSON object of string values into an attribute map.
fn parse_attributes_json(raw: &str) -> Result<BTreeMap<String, String>, AnalyticsConfigError> {
    let parsed: serde_json::Value = serde_json::from_str(raw)
        .map_err(|e| AnalyticsConfigError::InvalidAttributesJson(e.to_string()))?;
    let object = parsed.as_object().ok_or_else(|| {
        AnalyticsConfigError::InvalidAttributesJson("expected a JSON object".to_string())
    })?;

    let mut attributes = BTreeMap::new();
    for (name, value) in object {
        let value = value.as_str().ok_or_else(|| {
            AnalyticsConfigError::InvalidAttributesJson(format!(
                "value of `{}` is not a string",
                name
            ))
        })?;
        attributes.insert(name.clone(), value.to_string());
    }
    Ok(attributes)
}

/// Characters accepted in attribute values and site identifiers. Wide enough
/// for Plausible's comma-separated `data-domain`, narrow enough to exclude
/// every HTML metacharacter.
fn is_attribute_value_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | ':' | '/' | ',')
}

/// Characters accepted in a `localStorage` key.
fn is_storage_key_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
}

/// Reject anything that is not a plain `data-*` attribute name.
///
/// This is what keeps `onload`, `src` and `integrity` out of the rendered
/// tag: an administrator can add attributes, never behaviour.
fn validate_attribute_name(name: &str) -> Result<(), AnalyticsConfigError> {
    let Some(suffix) = name.strip_prefix("data-") else {
        return Err(AnalyticsConfigError::InvalidAttributeName(name.to_string()));
    };
    let valid = !suffix.is_empty()
        && suffix
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
    if valid {
        Ok(())
    } else {
        Err(AnalyticsConfigError::InvalidAttributeName(name.to_string()))
    }
}

fn validate_attribute_value(name: &str, value: &str) -> Result<(), AnalyticsConfigError> {
    if value.is_empty() {
        return Err(AnalyticsConfigError::InvalidAttributeValue {
            name: name.to_string(),
            reason: "value is empty".to_string(),
        });
    }
    if let Some(bad) = value.chars().find(|c| !is_attribute_value_char(*c)) {
        return Err(AnalyticsConfigError::InvalidAttributeValue {
            name: name.to_string(),
            reason: format!("unsupported character `{}`", bad),
        });
    }
    Ok(())
}

/// Accept absolute `https://` URLs, plus `http://` on a loopback host so the
/// feature can be exercised against a local collector in development.
fn validate_script_url(url: &str) -> Result<(), AnalyticsConfigError> {
    if url.chars().any(|c| c.is_control() || c.is_whitespace()) {
        return Err(AnalyticsConfigError::InvalidScriptUrl(
            "URL contains whitespace or control characters".to_string(),
        ));
    }
    if url.contains("..") {
        return Err(AnalyticsConfigError::InvalidScriptUrl(
            "URL contains a path traversal sequence".to_string(),
        ));
    }

    let (scheme, rest) = url.split_once("://").ok_or_else(|| {
        AnalyticsConfigError::InvalidScriptUrl("URL must be absolute".to_string())
    })?;

    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    if authority.is_empty() {
        return Err(AnalyticsConfigError::InvalidScriptUrl(
            "URL has no host".to_string(),
        ));
    }
    if authority.contains('@') {
        return Err(AnalyticsConfigError::InvalidScriptUrl(
            "URL must not carry credentials".to_string(),
        ));
    }

    let host = host_of_authority(authority);
    match scheme {
        "https" => Ok(()),
        "http" if is_loopback_host(host) => Ok(()),
        "http" => Err(AnalyticsConfigError::InvalidScriptUrl(
            "plain http is only allowed on a loopback host".to_string(),
        )),
        other => Err(AnalyticsConfigError::InvalidScriptUrl(format!(
            "unsupported scheme `{}`",
            other
        ))),
    }
}

/// Strip the port from an authority, handling bracketed IPv6 literals.
fn host_of_authority(authority: &str) -> &str {
    if let Some(end) = authority.strip_prefix('[').and_then(|r| r.find(']')) {
        return &authority[1..=end];
    }
    authority.split(':').next().unwrap_or(authority)
}

fn is_loopback_host(host: &str) -> bool {
    matches!(host, "localhost" | "127.0.0.1" | "::1")
}

/// Extract `scheme://host[:port]` from an absolute URL.
fn script_origin_of(url: &str) -> Option<String> {
    let (scheme, rest) = url.split_once("://")?;
    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    if authority.is_empty() {
        return None;
    }
    Some(format!("{}://{}", scheme, authority))
}

/// Escape the five characters that could break out of a double-quoted HTML
/// attribute. Defence in depth: [`AnalyticsConfig::validate`] already rejects
/// every one of them.
fn escape_html_attribute(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for c in value.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#x27;"),
            _ => out.push(c),
        }
    }
    out
}

/// Shared analytics state, injected as an Axum `Extension`.
///
/// The snippet is rendered when the state is loaded and whenever
/// [`Self::reload`] runs — never while serving a request. [`Self::snippet`]
/// is therefore a plain `Arc` clone with no database round-trip, which is
/// what makes the helper affordable on every page render.
#[derive(Debug)]
pub struct AnalyticsState {
    /// Configuration from the environment or the builder. Holds the script
    /// URL, which the database may not override.
    base: AnalyticsConfig,
    /// Pre-rendered snippet, `None` when tracking is off.
    rendered: RwLock<Arc<Option<String>>>,
}

impl AnalyticsState {
    /// Build the state from `base`, applying the `app_settings` overrides.
    ///
    /// # Errors
    ///
    /// Returns [`AppError`] if the settings cannot be read.
    pub async fn load(db: &DatabaseConnection, base: AnalyticsConfig) -> Result<Self, AppError> {
        let effective = Self::resolve(db, &base).await?;
        Ok(Self {
            base,
            rendered: RwLock::new(Arc::new(effective.render_snippet())),
        })
    }

    /// Build the state without consulting the database.
    ///
    /// Used when the framework runs without persisted settings, and in tests.
    pub fn from_config(base: AnalyticsConfig) -> Self {
        let rendered = base.render_snippet();
        Self {
            base,
            rendered: RwLock::new(Arc::new(rendered)),
        }
    }

    /// Re-read the `app_settings` overrides and re-render the snippet.
    ///
    /// Call this after an administrator saves the settings form so the change
    /// takes effect without a restart.
    ///
    /// # Errors
    ///
    /// Returns [`AppError`] if the settings cannot be read.
    pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError> {
        let effective = Self::resolve(db, &self.base).await?;
        let snippet = Arc::new(effective.render_snippet());
        match self.rendered.write() {
            Ok(mut guard) => *guard = snippet,
            // A poisoned lock means a previous writer panicked. The value is
            // still structurally sound (an Arc<Option<String>>), so recover
            // rather than propagate the panic to every subsequent request.
            Err(poisoned) => *poisoned.into_inner() = snippet,
        }
        Ok(())
    }

    /// The pre-rendered snippet, or `None` when tracking is off.
    pub fn snippet(&self) -> Arc<Option<String>> {
        match self.rendered.read() {
            Ok(guard) => Arc::clone(&guard),
            Err(poisoned) => Arc::clone(&poisoned.into_inner()),
        }
    }

    /// The `localStorage` key a visitor can set to opt out, if any.
    pub fn opt_out_storage_key(&self) -> Option<&str> {
        self.base.opt_out_storage_key.as_deref()
    }

    /// The script URL, which the database cannot override. Exposed so the
    /// administration screen can display it read-only.
    pub fn script_url(&self) -> Option<&str> {
        self.base.script_url.as_deref()
    }

    /// The environment/builder configuration, before any database override.
    ///
    /// The administration screen uses it as the starting point when checking
    /// a submitted form: the candidate must be validated against the real
    /// script URL and site-id attribute, not against defaults.
    pub fn base_config(&self) -> &AnalyticsConfig {
        &self.base
    }

    /// Merge `base` with the `app_settings` overrides.
    ///
    /// `script_url` is deliberately absent from the overrides: see the module
    /// documentation. An override that fails validation is discarded with a
    /// warning rather than taking the application down.
    async fn resolve(
        db: &DatabaseConnection,
        base: &AnalyticsConfig,
    ) -> Result<AnalyticsConfig, AppError> {
        let mut cfg = base.clone();

        cfg.enabled = AppSettingsService::get_bool(db, SETTING_ANALYTICS_ENABLED, base.enabled)
            .await
            .unwrap_or(base.enabled);

        if let Some(setting) =
            AppSettingsService::get_setting(db, SETTING_ANALYTICS_SITE_ID).await?
        {
            let value = setting.value.trim().to_string();
            cfg.site_id = if value.is_empty() { None } else { Some(value) };
        }

        if let Some(setting) =
            AppSettingsService::get_setting(db, SETTING_ANALYTICS_ATTRIBUTES).await?
        {
            let raw = setting.value.trim();
            if !raw.is_empty() {
                match parse_attributes_json(raw) {
                    Ok(attributes) => cfg.attributes = attributes,
                    Err(e) => tracing::warn!(
                        error = %e,
                        "ignoring stored analytics attributes: invalid payload"
                    ),
                }
            }
        }

        if let Err(e) = cfg.validate() {
            tracing::warn!(
                error = %e,
                "stored analytics settings are invalid; falling back to the environment configuration"
            );
            return Ok(base.clone());
        }

        Ok(cfg)
    }
}

/// Everything a template needs to render audience measurement.
///
/// Obtained as an Axum extractor, then placed in the handler's template
/// struct. Both fields are empty strings when the corresponding feature is
/// off, which keeps the Askama side free of `Option` handling.
///
/// # Rendering
///
/// ```text
/// {% if analytics.is_enabled() %}{{ analytics.snippet|safe }}{% endif %}
/// ```
///
/// The `|safe` filter is correct here: [`AnalyticsConfig::render_snippet`]
/// HTML-escapes every interpolated value, on top of the allow-list enforced
/// by [`AnalyticsConfig::validate`]. Never put unvalidated user input into
/// this field.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AnalyticsContext {
    /// The `<script>` tag, or an empty string when tracking is off.
    pub snippet: String,
    /// The `localStorage` opt-out key, or an empty string when the configured
    /// vendor exposes none.
    pub opt_out_storage_key: String,
    /// The configured script URL, or an empty string. Exposed so the
    /// administration screen can display it read-only; it is not a secret,
    /// since it appears in the page as soon as tracking is on.
    pub script_url: String,
}

impl AnalyticsContext {
    /// Build the context from the shared state, if the framework installed it.
    pub fn from_state(state: Option<&Arc<AnalyticsState>>) -> Self {
        let Some(state) = state else {
            return Self::default();
        };
        Self {
            snippet: state.snippet().as_deref().unwrap_or_default().to_string(),
            opt_out_storage_key: state.opt_out_storage_key().unwrap_or_default().to_string(),
            script_url: state.script_url().unwrap_or_default().to_string(),
        }
    }

    /// Whether a script URL is configured at all, whichever the runtime
    /// toggle. When false, the administration screen has nothing to offer.
    pub fn is_configured(&self) -> bool {
        !self.script_url.is_empty()
    }

    /// Whether a tracking script will be rendered.
    pub fn is_enabled(&self) -> bool {
        !self.snippet.is_empty()
    }

    /// Whether an opt-out control can be offered to the visitor.
    pub fn has_opt_out(&self) -> bool {
        !self.opt_out_storage_key.is_empty()
    }
}

impl<S> axum::extract::FromRequestParts<S> for AnalyticsContext
where
    S: Send + Sync,
{
    type Rejection = std::convert::Infallible;

    /// Never fails: an application built without analytics simply yields the
    /// empty context, so templates shared between configurations keep working.
    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        Ok(Self::from_state(
            parts.extensions.get::<Arc<AnalyticsState>>(),
        ))
    }
}

/// Handle on the shared [`AnalyticsState`], for the rare callers that need to
/// mutate it — chiefly the administration screen, which calls
/// [`AnalyticsState::reload`] after saving so the change takes effect without
/// a restart.
///
/// `None` when the application was not built through `RustwardenBuilder`, so
/// a consumer mounting the UI by hand still compiles and runs.
#[derive(Clone, Debug)]
pub struct AnalyticsHandle(pub Option<Arc<AnalyticsState>>);

impl AnalyticsHandle {
    /// The environment/builder configuration, when analytics is wired.
    pub fn base_config(&self) -> Option<&AnalyticsConfig> {
        self.0.as_ref().map(|state| state.base_config())
    }

    /// Re-read the settings, if analytics is wired. A missing handle is not
    /// an error: there is simply nothing to refresh.
    ///
    /// # Errors
    ///
    /// Returns [`AppError`] if the settings cannot be read.
    pub async fn reload(&self, db: &DatabaseConnection) -> Result<(), AppError> {
        match &self.0 {
            Some(state) => state.reload(db).await,
            None => Ok(()),
        }
    }
}

impl<S> axum::extract::FromRequestParts<S> for AnalyticsHandle
where
    S: Send + Sync,
{
    type Rejection = std::convert::Infallible;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        Ok(Self(parts.extensions.get::<Arc<AnalyticsState>>().cloned()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn enabled_config() -> AnalyticsConfig {
        AnalyticsConfig::default()
            .with_script_url("https://stats.example.org/api/script.js")
            .with_site_id("abc123")
            .enabled(true)
    }

    #[test]
    fn default_is_disabled_and_renders_nothing() {
        let cfg = AnalyticsConfig::default();
        assert!(!cfg.enabled);
        assert!(cfg.validate().is_ok());
        assert!(cfg.render_snippet().is_none());
        assert!(cfg.script_origin().is_none());
    }

    #[test]
    fn disabled_config_renders_nothing_even_with_a_url() {
        let cfg = enabled_config().enabled(false);
        assert!(cfg.render_snippet().is_none());
    }

    #[test]
    fn enabled_without_url_is_rejected() {
        let cfg = AnalyticsConfig::default().enabled(true);
        assert_eq!(cfg.validate(), Err(AnalyticsConfigError::MissingScriptUrl));
    }

    #[test]
    fn renders_expected_tag() {
        let cfg = enabled_config().with_attribute("data-consent", "opt-in");
        assert_eq!(
            cfg.render_snippet().as_deref(),
            Some(
                r#"<script src="https://stats.example.org/api/script.js" data-site-id="abc123" data-consent="opt-in" defer></script>"#
            )
        );
    }

    #[test]
    fn defer_can_be_turned_off() {
        let mut cfg = enabled_config();
        cfg.defer = false;
        let snippet = cfg.render_snippet().unwrap_or_default();
        assert!(!snippet.contains("defer"));
    }

    #[test]
    fn attribute_order_is_deterministic() {
        let cfg = enabled_config()
            .with_attribute("data-zulu", "1")
            .with_attribute("data-alpha", "2");
        let snippet = cfg.render_snippet().unwrap_or_default();
        let alpha = snippet.find("data-alpha").unwrap_or(usize::MAX);
        let zulu = snippet.find("data-zulu").unwrap_or(0);
        assert!(alpha < zulu, "attributes must render in sorted order");
    }

    // --- Security: injection ------------------------------------------------

    #[test]
    fn site_id_carrying_markup_is_rejected() {
        let cfg = enabled_config().with_site_id(r#""><script>alert(1)</script>"#);
        assert!(matches!(
            cfg.validate(),
            Err(AnalyticsConfigError::InvalidAttributeValue { .. })
        ));
    }

    #[test]
    fn markup_that_bypasses_validation_is_still_escaped() {
        // Defence in depth: build the struct directly, skipping `validate`.
        let mut cfg = enabled_config();
        cfg.site_id = Some(r#""><script>alert(1)</script>"#.to_string());
        let snippet = cfg.render_snippet().unwrap_or_default();
        assert!(!snippet.contains("<script>alert(1)"));
        assert!(snippet.contains("&quot;&gt;&lt;script&gt;"));
    }

    #[test]
    fn event_handler_attributes_are_rejected() {
        for name in ["onload", "onerror", "src", "integrity", "data-x onload"] {
            let cfg = enabled_config().with_attribute(name, "x");
            assert!(
                matches!(
                    cfg.validate(),
                    Err(AnalyticsConfigError::InvalidAttributeName(_))
                ),
                "attribute `{}` must be rejected",
                name
            );
        }
    }

    #[test]
    fn site_id_attribute_cannot_be_duplicated() {
        let cfg = enabled_config().with_attribute(DEFAULT_SITE_ID_ATTRIBUTE, "other");
        assert!(matches!(
            cfg.validate(),
            Err(AnalyticsConfigError::DuplicateAttribute(_))
        ));
    }

    // --- Security: URL ------------------------------------------------------

    #[test]
    fn plain_http_is_rejected_off_loopback() {
        let cfg = AnalyticsConfig::default()
            .with_script_url("http://stats.example.org/script.js")
            .enabled(true);
        assert!(matches!(
            cfg.validate(),
            Err(AnalyticsConfigError::InvalidScriptUrl(_))
        ));
    }

    #[test]
    fn plain_http_is_allowed_on_loopback() {
        for url in [
            "http://localhost:3001/script.js",
            "http://127.0.0.1:3001/script.js",
        ] {
            let cfg = AnalyticsConfig::default()
                .with_script_url(url)
                .enabled(true);
            assert!(cfg.validate().is_ok(), "{} should be accepted", url);
        }
    }

    #[test]
    fn malformed_urls_are_rejected() {
        for url in [
            "/relative/script.js",
            "https://",
            "javascript:alert(1)",
            "https://user:pass@evil.example/script.js",
            "https://stats.example.org/../script.js",
            "https://stats.example.org/script.js\" onload=\"alert(1)",
        ] {
            let cfg = AnalyticsConfig::default()
                .with_script_url(url)
                .enabled(true);
            assert!(
                matches!(
                    cfg.validate(),
                    Err(AnalyticsConfigError::InvalidScriptUrl(_))
                ),
                "{} should be rejected",
                url
            );
        }
    }

    // --- Origin -------------------------------------------------------------

    #[test]
    fn script_origin_drops_path_and_keeps_port() {
        let cases = [
            (
                "https://stats.example.org/api/script.js",
                "https://stats.example.org",
            ),
            (
                "https://stats.example.org:8443/s.js",
                "https://stats.example.org:8443",
            ),
            (
                "https://a.b.c.example.org/s.js?v=1",
                "https://a.b.c.example.org",
            ),
            ("http://localhost:3001/s.js", "http://localhost:3001"),
        ];
        for (url, expected) in cases {
            let cfg = AnalyticsConfig::default().with_script_url(url);
            assert_eq!(
                cfg.script_origin().as_deref(),
                Some(expected),
                "for {}",
                url
            );
        }
    }

    #[test]
    fn script_origin_is_available_even_when_disabled() {
        // The CSP is built at startup and must allow the origin regardless of
        // the runtime toggle, which may be flipped on later from the DB.
        let cfg = enabled_config().enabled(false);
        assert_eq!(
            cfg.script_origin().as_deref(),
            Some("https://stats.example.org")
        );
    }

    // --- Opt-out key --------------------------------------------------------

    #[test]
    fn opt_out_key_charset_is_enforced() {
        let ok = enabled_config().with_opt_out_storage_key("disable-rybbit");
        assert!(ok.validate().is_ok());

        let bad = enabled_config().with_opt_out_storage_key("disable rybbit<>");
        assert!(matches!(
            bad.validate(),
            Err(AnalyticsConfigError::InvalidOptOutKey(_))
        ));
    }

    // --- JSON attributes ----------------------------------------------------

    #[test]
    fn attributes_json_is_parsed() {
        let parsed = parse_attributes_json(r#"{"data-consent":"opt-in"}"#).unwrap_or_default();
        assert_eq!(
            parsed.get("data-consent").map(String::as_str),
            Some("opt-in")
        );
    }

    #[test]
    fn attributes_json_rejects_non_objects_and_non_strings() {
        assert!(parse_attributes_json("[1,2]").is_err());
        assert!(parse_attributes_json(r#"{"data-x":42}"#).is_err());
        assert!(parse_attributes_json("not json").is_err());
    }

    // --- State --------------------------------------------------------------

    #[test]
    fn state_pre_renders_the_snippet() {
        let state = AnalyticsState::from_config(enabled_config());
        assert!(state.snippet().is_some());
        assert_eq!(
            state.script_url(),
            Some("https://stats.example.org/api/script.js")
        );
    }

    #[test]
    fn state_of_a_disabled_config_yields_nothing() {
        let state = AnalyticsState::from_config(AnalyticsConfig::default());
        assert!(state.snippet().is_none());
        assert!(state.opt_out_storage_key().is_none());
    }

    // --- Template context ---------------------------------------------------

    #[test]
    fn context_without_state_is_empty() {
        let ctx = AnalyticsContext::from_state(None);
        assert!(!ctx.is_enabled());
        assert!(!ctx.has_opt_out());
        assert_eq!(ctx.snippet, "");
        assert_eq!(ctx.opt_out_storage_key, "");
    }

    #[test]
    fn context_carries_snippet_and_opt_out_key() {
        let state = Arc::new(AnalyticsState::from_config(
            enabled_config().with_opt_out_storage_key("disable-rybbit"),
        ));
        let ctx = AnalyticsContext::from_state(Some(&state));

        assert!(ctx.is_enabled());
        assert!(ctx.has_opt_out());
        assert!(ctx.snippet.contains("data-site-id=\"abc123\""));
        assert_eq!(ctx.opt_out_storage_key, "disable-rybbit");
    }

    #[test]
    fn context_of_disabled_analytics_still_exposes_nothing() {
        let state = Arc::new(AnalyticsState::from_config(enabled_config().enabled(false)));
        let ctx = AnalyticsContext::from_state(Some(&state));
        assert!(!ctx.is_enabled());
    }
}