Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.
analyticsaxumdockerrustself-hostedsqliteviteweb-analytics
1use axum::{
2 extract::{Form, Path as AxumPath, Query, State},
3 http::StatusCode,
4 response::{IntoResponse, Json, Redirect, Response},
5 routing::{get, post},
6 Router,
7};
8use serde::Deserialize;
9use serde_json::json;
10use tower_cookies::Cookies;
11use uuid::Uuid;
12
13use crate::render::render;
14use crate::routes::auth::is_authenticated;
15use crate::AppState;
16
17pub fn router() -> Router<AppState> {
18 Router::new()
19 .route("/properties", get(properties).post(properties_create))
20 .route("/properties/{id}/delete", post(property_delete))
21 .route("/properties/{id}/cards", post(property_cards))
22 .route("/properties/{id}/public", post(property_public_toggle))
23}
24
25#[derive(Debug, Deserialize)]
26pub struct PropertiesQuery {
27 #[serde(default)]
28 pub q: Option<String>,
29}
30
31#[derive(Debug, Deserialize)]
32pub struct PropertyCreateForm {
33 pub name: String,
34}
35
36pub async fn properties(
37 State(state): State<AppState>,
38 cookies: Cookies,
39 Query(q): Query<PropertiesQuery>,
40) -> Response {
41 if !is_authenticated(&cookies, &state) {
42 return Redirect::to("/login").into_response();
43 }
44 let search = q.q.as_deref().unwrap_or("").trim().to_string();
45
46 let rows = if search.is_empty() {
47 sqlx::query_as::<_, crate::models::PropertyRow>(
48 "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \
49 FROM properties ORDER BY is_protected DESC, created_at ASC",
50 )
51 .fetch_all(&state.pool)
52 .await
53 } else {
54 let like = format!("%{}%", search);
55 sqlx::query_as::<_, crate::models::PropertyRow>(
56 "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \
57 FROM properties WHERE name LIKE ? ORDER BY is_protected DESC, created_at ASC",
58 )
59 .bind(like)
60 .fetch_all(&state.pool)
61 .await
62 };
63
64 let rows = match rows {
65 Ok(r) => r,
66 Err(e) => {
67 tracing::error!("properties query: {e}");
68 return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
69 }
70 };
71
72 let mut props = Vec::with_capacity(rows.len());
73 let mut total_events = 0i64;
74 let mut total_page_views = 0i64;
75 let mut total_sessions = 0i64;
76
77 // 7 days in ms. Used as the "active in the last week" threshold for
78 // marking a property as live in the list view.
79 const ACTIVE_WINDOW_MS: i64 = 7 * 24 * 60 * 60 * 1000;
80
81 for row in rows {
82 let id_bytes = row.id.clone();
83 let p = row.into_property();
84
85 let counts: (i64, i64, i64, i64) = sqlx::query_as(
86 "SELECT \
87 (SELECT COUNT(*) FROM events WHERE property_id = ?1) AS total, \
88 (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'page_view') AS pv, \
89 (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'session_start') AS ss, \
90 (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND created_at >= ?2) AS active",
91 )
92 .bind(&id_bytes)
93 .bind(chrono::Utc::now().timestamp_millis() - ACTIVE_WINDOW_MS)
94 .fetch_one(&state.pool)
95 .await
96 .unwrap_or((0, 0, 0, 0));
97
98 total_events += counts.0;
99 total_page_views += counts.1;
100 total_sessions += counts.2;
101
102 props.push(json!({
103 "id": p.id.to_string(),
104 "name": p.name,
105 "is_protected": p.is_protected,
106 "is_public": p.is_public,
107 "is_active": counts.3 > 0,
108 "total_events": counts.0,
109 "total_page_views": counts.1,
110 "total_session_starts": counts.2,
111 }));
112 }
113
114 let totals = json!({
115 "properties": props.len(),
116 "events": total_events,
117 "page_views": total_page_views,
118 "sessions": total_sessions,
119 });
120
121 let extra = minijinja::context! {
122 page => minijinja::context! {
123 title => "Properties",
124 description => "Manage your properties.",
125 },
126 properties => &props,
127 totals => &totals,
128 q => &search,
129 };
130
131 render(&state, "properties/properties.html", "/properties", true, extra)
132}
133
134pub async fn properties_create(
135 State(state): State<AppState>,
136 cookies: Cookies,
137 Form(form): Form<PropertyCreateForm>,
138) -> Response {
139 if !is_authenticated(&cookies, &state) {
140 return Redirect::to("/login").into_response();
141 }
142 let name = form.name.trim();
143 if name.is_empty() {
144 return Redirect::to("/properties").into_response();
145 }
146 let id = Uuid::new_v4();
147 let now = chrono::Utc::now().timestamp_millis();
148 let res = sqlx::query(
149 "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \
150 VALUES (?, ?, '[]', 0, 0, ?, ?)",
151 )
152 .bind(id.as_bytes().to_vec())
153 .bind(name)
154 .bind(now)
155 .bind(now)
156 .execute(&state.pool)
157 .await;
158 if let Err(e) = res {
159 tracing::error!("create property: {e}");
160 return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();
161 }
162 Redirect::to("/properties").into_response()
163}
164
165pub async fn property_delete(
166 State(state): State<AppState>,
167 AxumPath(property_id): AxumPath<Uuid>,
168 cookies: Cookies,
169) -> Response {
170 if !is_authenticated(&cookies, &state) {
171 return Redirect::to("/login").into_response();
172 }
173 let _ = sqlx::query("DELETE FROM properties WHERE id = ? AND is_protected = 0")
174 .bind(property_id.as_bytes().to_vec())
175 .execute(&state.pool)
176 .await;
177 Redirect::to("/properties").into_response()
178}
179
180pub async fn property_cards(
181 State(state): State<AppState>,
182 AxumPath(property_id): AxumPath<Uuid>,
183 cookies: Cookies,
184 body: String,
185) -> Response {
186 if !is_authenticated(&cookies, &state) {
187 return Redirect::to("/login").into_response();
188 }
189 // Body is the raw JSON array of {event,value} objects.
190 let parsed: serde_json::Value =
191 serde_json::from_str(&body).unwrap_or(serde_json::json!([]));
192 let payload = parsed.to_string();
193 let now = chrono::Utc::now().timestamp_millis();
194 let _ = sqlx::query("UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?")
195 .bind(payload)
196 .bind(now)
197 .bind(property_id.as_bytes().to_vec())
198 .execute(&state.pool)
199 .await;
200 Json(serde_json::json!({"success": true})).into_response()
201}
202
203pub async fn property_public_toggle(
204 State(state): State<AppState>,
205 AxumPath(property_id): AxumPath<Uuid>,
206 cookies: Cookies,
207) -> Response {
208 if !is_authenticated(&cookies, &state) {
209 return Redirect::to("/login").into_response();
210 }
211 let now = chrono::Utc::now().timestamp_millis();
212 let _ = sqlx::query("UPDATE properties SET is_public = 1 - is_public, updated_at = ? WHERE id = ?")
213 .bind(now)
214 .bind(property_id.as_bytes().to_vec())
215 .execute(&state.pool)
216 .await;
217 Json(serde_json::json!({"success": true})).into_response()
218}