Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
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(®exes_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}