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

6.9 KB · 219 lines · Rust Raw History
  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}