repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

5.7 KB · 166 lines · Rust Raw History
  1use axum::{http::HeaderValue, middleware as axum_middleware, Router};
  2use minijinja::Environment;
  3use sqlx::SqlitePool;
  4use std::path::PathBuf;
  5use std::sync::Arc;
  6use tower_cookies::{CookieManagerLayer, Key};
  7use tower_http::services::ServeDir;
  8use tower_http::set_header::SetResponseHeaderLayer;
  9
 10use crate::geoip::{self, GeoIp};
 11use crate::pdf::PdfRenderer;
 12use crate::routes;
 13use crate::ua::{self, UaParser};
 14use crate::{db, middleware, templates};
 15
 16#[derive(Clone)]
 17pub struct AppState {
 18    pub env: Arc<Environment<'static>>,
 19    pub pool: SqlitePool,
 20    pub cookie_key: Key,
 21    pub geoip: Arc<GeoIp>,
 22    pub ua: Arc<UaParser>,
 23    pub config: Arc<Config>,
 24    pub pdf_renderer: Arc<PdfRenderer>,
 25}
 26
 27#[derive(Debug, Clone)]
 28pub struct Config {
 29    pub root: PathBuf,
 30    pub data_dir: PathBuf,
 31    pub password: String,
 32    pub base_url: String,
 33    pub proprium_id: Option<uuid::Uuid>,
 34}
 35
 36impl AppState {
 37    pub async fn from_env() -> anyhow::Result<Self> {
 38        let root: PathBuf = std::env::var("ANALYTICS_ROOT")
 39            .map(PathBuf::from)
 40            .unwrap_or_else(|_| PathBuf::from("."));
 41        let data_dir = std::env::var("ANALYTICS_DATA_DIR")
 42            .map(PathBuf::from)
 43            .unwrap_or_else(|_| root.join("data"));
 44        std::fs::create_dir_all(&data_dir)?;
 45
 46        let password = std::env::var("ANALYTICS_PASSWORD")
 47            .ok()
 48            .filter(|p| !p.is_empty())
 49            .ok_or_else(|| {
 50                anyhow::anyhow!(
 51                    "ANALYTICS_PASSWORD is not set; refusing to start an internet-facing \
 52                     server with a guessable default"
 53                )
 54            })?;
 55        let base_url = std::env::var("BASE_URL").unwrap_or_default();
 56
 57        let cookie_secret = std::env::var("ANALYTICS_COOKIE_SECRET").unwrap_or_else(|_| {
 58            // 64+ bytes derived from password if no secret provided. For a single-user
 59            // self-hosted app this is fine; setting ANALYTICS_COOKIE_SECRET is preferred.
 60            use sha2::{Digest, Sha512};
 61            let mut h = Sha512::new();
 62            h.update(b"analytics-cookie:");
 63            h.update(password.as_bytes());
 64            let digest = h.finalize();
 65            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)
 66        });
 67        let cookie_key = Key::from(cookie_secret.as_bytes());
 68
 69        let pool = db::init(&data_dir).await?;
 70        let proprium_id = db::ensure_proprium(&pool).await?;
 71        tracing::info!("Proprium property: {}", proprium_id);
 72
 73        let geoip = Arc::new(GeoIp::load(&data_dir.join("db.mmdb")));
 74        let ua = Arc::new(UaParser::load(&data_dir.join("regexes.yaml")));
 75
 76        let templates_dir = root.join("templates");
 77        let manifest_path = root.join("dist/.vite/manifest.json");
 78        let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));
 79
 80        let pdf_renderer = Arc::new(PdfRenderer::new(root.clone()));
 81
 82        let config = Arc::new(Config {
 83            root,
 84            data_dir,
 85            password,
 86            base_url,
 87            proprium_id: Some(proprium_id),
 88        });
 89
 90        Ok(Self {
 91            env,
 92            pool,
 93            cookie_key,
 94            geoip,
 95            ua,
 96            config,
 97            pdf_renderer,
 98        })
 99    }
100}
101
102/// Best-effort background downloads. The server boots immediately; once these
103/// finish the next collector hit picks up the loaded data. Failures are logged
104/// and ignored so a flaky network doesn't block the server from running.
105pub fn spawn_background_downloads(state: &AppState) {
106    let geoip = state.geoip.clone();
107    let geoip_path = state.config.data_dir.join("db.mmdb");
108    tokio::spawn(async move {
109        match geoip::ensure_db(&geoip_path).await {
110            Ok(true) => {
111                geoip.reload();
112            }
113            Ok(false) => {}
114            Err(e) => tracing::warn!("geoip download skipped: {e}"),
115        }
116    });
117
118    let regexes_path = state.config.data_dir.join("regexes.yaml");
119    tokio::spawn(async move {
120        if let Err(e) = ua::ensure_regexes(&regexes_path).await {
121            tracing::warn!("uaparser regexes download skipped: {e}");
122        }
123        // Note: hot-reload of UA parser would need RwLock too. For now
124        // the download primes the file for the next process restart.
125    });
126}
127
128pub fn router(state: AppState) -> Router {
129    let dist_dir = state.config.root.join("dist");
130    let static_maps_dir = state.config.root.join("static_maps");
131
132    let static_cache = SetResponseHeaderLayer::if_not_present(
133        axum::http::header::CACHE_CONTROL,
134        HeaderValue::from_static("public, max-age=31536000"),
135    );
136
137    Router::new()
138        // Per-feature routers. CORS lives inside routes::collector so it's
139        // scoped to /collect (the only endpoint that is cross-origin by
140        // design). Same-origin routes don't need it.
141        .merge(routes::home::router())
142        .merge(routes::auth::router())
143        .merge(routes::seo::router())
144        .merge(routes::collector::router())
145        .merge(routes::properties::router())
146        // routes::dashboard holds the UUID `/{property_id}` catch-all; merge
147        // last so named routes win the match.
148        .merge(routes::dashboard::router())
149        .nest_service(
150            "/static",
151            tower::ServiceBuilder::new()
152                .layer(static_cache.clone())
153                .service(ServeDir::new(&dist_dir)),
154        )
155        .nest_service(
156            "/static_maps",
157            tower::ServiceBuilder::new()
158                .layer(static_cache)
159                .service(ServeDir::new(&static_maps_dir)),
160        )
161        .fallback(middleware::not_found)
162        .layer(CookieManagerLayer::new())
163        .layer(axum_middleware::from_fn(middleware::log_requests))
164        .with_state(state)
165}