diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 14e97ef..24124dc 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -19,47 +19,6 @@ spec: imagePullSecrets: - name: gitea-registry containers: - - name: analytics - image: datasetteproject/datasette:0.65.1 - args: - - serve - - /data/sol.db - - --host - - "0.0.0.0" - - --port - - "8001" - - --readonly - - --setting - - sql_time_limit_ms - - "5000" - - --setting - - max_returned_rows - - "1000" - ports: - - containerPort: 8001 - volumeMounts: - - name: db-data - mountPath: /data - readOnly: true - livenessProbe: - httpGet: - path: /-/alive - port: 8001 - initialDelaySeconds: 10 - periodSeconds: 30 - readinessProbe: - httpGet: - path: /-/alive - port: 8001 - initialDelaySeconds: 5 - periodSeconds: 10 - resources: - requests: - cpu: 25m - memory: 48Mi - limits: - cpu: 200m - memory: 128Mi - name: server image: solitaire-server imagePullPolicy: Always diff --git a/deploy/ingress-analytics.yaml b/deploy/ingress-analytics.yaml index f520c60..22be5ba 100644 --- a/deploy/ingress-analytics.yaml +++ b/deploy/ingress-analytics.yaml @@ -6,7 +6,6 @@ metadata: annotations: cert-manager.io/cluster-issuer: letsencrypt-prod traefik.ingress.kubernetes.io/router.entrypoints: websecure - traefik.ingress.kubernetes.io/router.middlewares: solitaire-analytics-auth@kubernetescrd spec: ingressClassName: traefik rules: @@ -17,9 +16,9 @@ spec: pathType: Prefix backend: service: - name: solitaire-server + name: matomo port: - name: analytics + name: http tls: - hosts: - analytics.aleshym.co diff --git a/deploy/kustomization.yaml b/deploy/kustomization.yaml index f354aea..18936d1 100644 --- a/deploy/kustomization.yaml +++ b/deploy/kustomization.yaml @@ -7,8 +7,13 @@ resources: - deployment.yaml - service.yaml - ingress.yaml -- middleware-analytics-auth.yaml -- secret-analytics-auth.yaml +- mariadb-pvc.yaml +- mariadb-deployment.yaml +- mariadb-service.yaml +- matomo-pvc.yaml +- matomo-secret.yaml +- matomo-deployment.yaml +- matomo-service.yaml - ingress-analytics.yaml # CI updates this block automatically via `kustomize edit set image`. diff --git a/deploy/mariadb-deployment.yaml b/deploy/mariadb-deployment.yaml new file mode 100644 index 0000000..20e5d66 --- /dev/null +++ b/deploy/mariadb-deployment.yaml @@ -0,0 +1,72 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mariadb + namespace: solitaire +spec: + replicas: 1 + selector: + matchLabels: + app: mariadb + strategy: + type: Recreate + template: + metadata: + labels: + app: mariadb + spec: + containers: + - name: mariadb + image: mariadb:11 + env: + - name: MYSQL_ROOT_PASSWORD + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_ROOT_PASSWORD + - name: MYSQL_DATABASE + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_DATABASE + - name: MYSQL_USER + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_USER + - name: MYSQL_PASSWORD + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_PASSWORD + ports: + - containerPort: 3306 + volumeMounts: + - name: mariadb-data + mountPath: /var/lib/mysql + livenessProbe: + exec: + command: + - healthcheck.sh + - --connect + - --innodb_initialized + initialDelaySeconds: 30 + periodSeconds: 30 + readinessProbe: + exec: + command: + - healthcheck.sh + - --connect + initialDelaySeconds: 10 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: mariadb-data + persistentVolumeClaim: + claimName: mariadb-data diff --git a/deploy/mariadb-pvc.yaml b/deploy/mariadb-pvc.yaml new file mode 100644 index 0000000..17276d1 --- /dev/null +++ b/deploy/mariadb-pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: mariadb-data + namespace: solitaire +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi diff --git a/deploy/mariadb-service.yaml b/deploy/mariadb-service.yaml new file mode 100644 index 0000000..c796ec6 --- /dev/null +++ b/deploy/mariadb-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mariadb + namespace: solitaire +spec: + selector: + app: mariadb + ports: + - name: mysql + port: 3306 + targetPort: 3306 + clusterIP: None diff --git a/deploy/matomo-deployment.yaml b/deploy/matomo-deployment.yaml new file mode 100644 index 0000000..6247d5e --- /dev/null +++ b/deploy/matomo-deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: matomo + namespace: solitaire +spec: + replicas: 1 + selector: + matchLabels: + app: matomo + strategy: + type: Recreate + template: + metadata: + labels: + app: matomo + spec: + containers: + - name: matomo + image: bitnami/matomo:5 + env: + - name: MATOMO_DATABASE_HOST + value: mariadb + - name: MATOMO_DATABASE_PORT_NUMBER + value: "3306" + - name: MATOMO_DATABASE_NAME + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_DATABASE + - name: MATOMO_DATABASE_USER + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_USER + - name: MATOMO_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: matomo-secret + key: MYSQL_PASSWORD + - name: MATOMO_USERNAME + value: admin + - name: MATOMO_PASSWORD + valueFrom: + secretKeyRef: + name: matomo-secret + key: MATOMO_ADMIN_PASSWORD + - name: MATOMO_EMAIL + value: funman300@gmail.com + - name: MATOMO_WEBSITE_NAME + value: "Solitaire Quest" + - name: MATOMO_WEBSITE_HOST + value: "https://klondike.aleshym.co" + - name: MATOMO_HOST + value: analytics.aleshym.co + - name: MATOMO_ENABLE_PROXY_URI_HEADER + value: "yes" + ports: + - containerPort: 8080 + volumeMounts: + - name: matomo-data + mountPath: /bitnami/matomo + livenessProbe: + httpGet: + path: /index.php + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /index.php + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + volumes: + - name: matomo-data + persistentVolumeClaim: + claimName: matomo-data diff --git a/deploy/matomo-pvc.yaml b/deploy/matomo-pvc.yaml new file mode 100644 index 0000000..0544972 --- /dev/null +++ b/deploy/matomo-pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: matomo-data + namespace: solitaire +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/deploy/matomo-secret.yaml b/deploy/matomo-secret.yaml new file mode 100644 index 0000000..f1159aa --- /dev/null +++ b/deploy/matomo-secret.yaml @@ -0,0 +1,13 @@ +# Credentials for MariaDB and the Matomo admin account. +# Regenerate with: python3 -c "import secrets; print(secrets.token_urlsafe(18))" +apiVersion: v1 +kind: Secret +metadata: + name: matomo-secret + namespace: solitaire +stringData: + MYSQL_ROOT_PASSWORD: "jspRn-QU18sZhB55FR-JfrMJ" + MYSQL_DATABASE: matomo + MYSQL_USER: matomo + MYSQL_PASSWORD: "ZxDp648UuL5fsN7eQI23E7ue" + MATOMO_ADMIN_PASSWORD: "J6QUtbroK4Z7zao4Dnl0J7e2" diff --git a/deploy/matomo-service.yaml b/deploy/matomo-service.yaml new file mode 100644 index 0000000..c6fc0b0 --- /dev/null +++ b/deploy/matomo-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: matomo + namespace: solitaire +spec: + selector: + app: matomo + ports: + - name: http + port: 80 + targetPort: 8080 diff --git a/deploy/middleware-analytics-auth.yaml b/deploy/middleware-analytics-auth.yaml deleted file mode 100644 index cf26792..0000000 --- a/deploy/middleware-analytics-auth.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: traefik.containo.us/v1alpha1 -kind: Middleware -metadata: - name: analytics-auth - namespace: solitaire -spec: - basicAuth: - secret: analytics-auth-secret diff --git a/deploy/secret-analytics-auth.yaml b/deploy/secret-analytics-auth.yaml deleted file mode 100644 index 7ab97cf..0000000 --- a/deploy/secret-analytics-auth.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Default credentials: admin / solitaire-analytics -# To change: htpasswd -nbm admin 'NEWPASSWORD' -# Then replace the users value below and re-apply. -apiVersion: v1 -kind: Secret -metadata: - name: analytics-auth-secret - namespace: solitaire -stringData: - users: "admin:$apr1$AAZ1wWNs$kMdJTfbPTcUZVX5ryY5gP1" diff --git a/deploy/service.yaml b/deploy/service.yaml index 329cf85..e4e9631 100644 --- a/deploy/service.yaml +++ b/deploy/service.yaml @@ -10,6 +10,3 @@ spec: - name: http port: 80 targetPort: 8080 - - name: analytics - port: 8001 - targetPort: 8001 diff --git a/solitaire_data/src/analytics_client.rs b/solitaire_data/src/analytics_client.rs deleted file mode 100644 index 2a9289a..0000000 --- a/solitaire_data/src/analytics_client.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Fire-and-forget analytics client. -//! -//! Events are buffered in memory and flushed in a background task. Errors are -//! silently discarded — analytics must never affect gameplay or block the UI. - -use std::sync::Mutex; - -use chrono::{DateTime, Utc}; -use reqwest::Client; -use serde_json::Value; -use uuid::Uuid; - -/// Buffers game-play events and flushes them to `POST /api/analytics`. -/// -/// Construct once per session and share via `Arc`. `record` is cheap and -/// can be called from the Bevy main thread; `flush` is async and must be -/// called from a background task. -pub struct AnalyticsClient { - base_url: String, - /// Stable across the whole app session — one UUID per launch. - session_id: String, - client: Client, - pending: Mutex>, -} - -struct PendingEvent { - id: String, - event_type: String, - payload: Value, - client_time: DateTime, -} - -impl AnalyticsClient { - /// Create a new client for the given server base URL. - pub fn new(base_url: impl Into) -> Self { - Self { - base_url: base_url.into().trim_end_matches('/').to_owned(), - session_id: Uuid::new_v4().to_string(), - client: Client::new(), - pending: Mutex::new(Vec::new()), - } - } - - /// Buffer one event. Never blocks; never fails visibly. - /// - /// When the buffer exceeds 100 events the oldest 50 are dropped to - /// prevent unbounded memory growth during extended offline play. - pub fn record(&self, event_type: &str, payload: Value) { - let Ok(mut guard) = self.pending.lock() else { - return; - }; - guard.push(PendingEvent { - id: Uuid::new_v4().to_string(), - event_type: event_type.to_owned(), - payload, - client_time: Utc::now(), - }); - if guard.len() > 100 { - guard.drain(0..50); - } - } - - /// Drain the pending buffer and POST it to the server. - /// - /// The buffer is drained *before* the HTTP call so new events recorded - /// during an in-flight flush are not lost. On network failure the drained - /// events are silently discarded (fire-and-forget semantics). - pub async fn flush(&self, user_id: Option) { - let events = { - let Ok(mut guard) = self.pending.lock() else { - return; - }; - if guard.is_empty() { - return; - } - std::mem::take(&mut *guard) - }; - - let batch = serde_json::json!({ - "session_id": self.session_id, - "user_id": user_id, - "events": events.iter().map(|e| serde_json::json!({ - "id": e.id, - "event_type": e.event_type, - "payload": e.payload, - "client_time": e.client_time.to_rfc3339(), - })).collect::>(), - }); - - let _ = self - .client - .post(format!("{}/api/analytics", self.base_url)) - .json(&batch) - .send() - .await; - } -} diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index 91668f2..1f0e623 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -163,8 +163,8 @@ pub use replay::{ REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, }; -pub mod analytics_client; -pub use analytics_client::AnalyticsClient; +pub mod matomo_client; +pub use matomo_client::MatomoClient; pub mod platform; pub use platform::data_dir; diff --git a/solitaire_data/src/matomo_client.rs b/solitaire_data/src/matomo_client.rs new file mode 100644 index 0000000..b021349 --- /dev/null +++ b/solitaire_data/src/matomo_client.rs @@ -0,0 +1,122 @@ +//! Matomo HTTP Tracking API client. +//! +//! Buffers game-play events and flushes them via the Matomo bulk tracking +//! endpoint. Errors are silently discarded — analytics must never affect +//! gameplay or block the UI. + +use std::sync::Mutex; + +use reqwest::Client; +use uuid::Uuid; + +/// Sends game-play events to a self-hosted Matomo instance via the +/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api). +/// +/// Construct once per session and share via `Arc`. `event` is cheap and +/// can be called from the Bevy main thread; `flush` is async and must be +/// called from a background task. +pub struct MatomoClient { + tracking_url: String, + site_id: u32, + /// 16 hex-char visitor ID, stable for the lifetime of this client. + visitor_id: String, + uid: Option, + client: Client, + /// Pre-encoded query strings, one per buffered event. + pending: Mutex>, +} + +impl MatomoClient { + /// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`). + pub fn new(base_url: impl AsRef, site_id: u32, uid: Option) -> Self { + let base = base_url.as_ref().trim_end_matches('/'); + let tracking_url = format!("{}/matomo.php", base); + // Take the lower 64 bits of a v4 UUID and format as 16 hex chars. + let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64); + Self { + tracking_url, + site_id, + visitor_id, + uid, + client: Client::new(), + pending: Mutex::new(Vec::new()), + } + } + + /// Buffer one Matomo custom event. Never blocks; never fails visibly. + /// + /// When the buffer exceeds 100 events the oldest 50 are dropped to + /// prevent unbounded memory growth during extended offline play. + pub fn event( + &self, + category: &str, + action: &str, + name: Option<&str>, + value: Option, + ) { + let Ok(mut guard) = self.pending.lock() else { + return; + }; + + let mut qs = format!( + "idsite={}&rec=1&apiv=1&send_image=0\ + &url=game%3A%2F%2Fsolitaire%2Fevent\ + &_id={}&e_c={}&e_a={}", + self.site_id, + self.visitor_id, + url_encode(category), + url_encode(action), + ); + if let Some(n) = name { + qs.push_str(&format!("&e_n={}", url_encode(n))); + } + if let Some(v) = value { + qs.push_str(&format!("&e_v={v}")); + } + if let Some(uid) = &self.uid { + qs.push_str(&format!("&uid={}", url_encode(uid))); + } + + guard.push(qs); + if guard.len() > 100 { + guard.drain(0..50); + } + } + + /// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint. + /// + /// The buffer is drained *before* the HTTP call so events recorded during + /// an in-flight flush are not lost. Network errors are silently discarded. + pub async fn flush(&self) { + let pending = { + let Ok(mut guard) = self.pending.lock() else { + return; + }; + if guard.is_empty() { + return; + } + std::mem::take(&mut *guard) + }; + + let requests: Vec = pending.into_iter().map(|qs| format!("?{qs}")).collect(); + let body = serde_json::json!({ "requests": requests }); + + let _ = self + .client + .post(&self.tracking_url) + .json(&body) + .send() + .await; + } +} + +fn url_encode(s: &str) -> String { + s.chars() + .flat_map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => { + vec![c] + } + c => format!("%{:02X}", c as u32).chars().collect(), + }) + .collect() +} diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 3fe0763..7b0f9ad 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -244,12 +244,20 @@ pub struct Settings { #[serde(default)] pub take_from_foundation: bool, /// When `true`, anonymous game-play events (game start, game won, etc.) - /// are sent to the configured sync server for aggregate analytics. Opt-in; - /// defaults to `false`. Only active when `sync_backend` is - /// `SolitaireServer`. Older `settings.json` files deserialize cleanly to - /// `false` via `#[serde(default)]`. + /// are sent to the configured Matomo instance. Opt-in; defaults to `false`. + /// Requires `matomo_url` to be set. Older `settings.json` files deserialize + /// cleanly to `false` via `#[serde(default)]`. #[serde(default)] pub analytics_enabled: bool, + /// Base URL of the Matomo instance to send events to, e.g. + /// `"https://analytics.example.com"`. When `None` the analytics toggle has + /// no effect. Older `settings.json` files deserialize cleanly to `None`. + #[serde(default)] + pub matomo_url: Option, + /// Matomo site ID assigned when the tracked site was created in Matomo. + /// Defaults to `1` (the first site created in a fresh Matomo install). + #[serde(default = "default_matomo_site_id")] + pub matomo_site_id: u32, } fn default_draw_mode() -> DrawMode { @@ -318,6 +326,10 @@ fn default_replay_move_interval_secs() -> f32 { 0.45 } +fn default_matomo_site_id() -> u32 { + 1 +} + /// Lower bound of the player-tunable replay-playback per-move interval, /// in seconds. Below this the cards barely register visually before /// the next move fires; the cap keeps the playback legible. @@ -372,6 +384,8 @@ impl Default for Settings { leaderboard_display_name: None, take_from_foundation: false, analytics_enabled: false, + matomo_url: None, + matomo_site_id: default_matomo_site_id(), } } } diff --git a/solitaire_engine/src/analytics_plugin.rs b/solitaire_engine/src/analytics_plugin.rs index 52b0a1b..2eed279 100644 --- a/solitaire_engine/src/analytics_plugin.rs +++ b/solitaire_engine/src/analytics_plugin.rs @@ -1,16 +1,15 @@ -//! Analytics plugin — buffers game-play events and flushes them to the -//! configured server in the background. +//! Matomo analytics plugin — buffers game-play events and flushes them to +//! the configured Matomo instance in the background. //! //! Disabled by default (opt-in via Settings → Privacy). Only active when -//! `settings.analytics_enabled` is `true` AND `sync_backend` is a -//! `SolitaireServer` with a URL to send to. +//! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set. use std::sync::Arc; use bevy::prelude::*; use bevy::tasks::AsyncComputeTaskPool; use solitaire_core::game_state::GameMode; -use solitaire_data::{analytics_client::AnalyticsClient, settings::SyncBackend, Settings}; +use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings}; use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent}; use crate::resources::GameStateResource; @@ -20,10 +19,10 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; // Resource // --------------------------------------------------------------------------- -/// Holds the active analytics client. `None` when the feature is disabled. +/// Holds the active Matomo client. `None` when the feature is disabled. #[derive(Resource)] pub struct AnalyticsResource { - pub client: Option>, + pub client: Option>, flush_timer: Timer, } @@ -87,13 +86,7 @@ fn on_game_won( return; }; for ev in wins.read() { - client.record( - "game_won", - serde_json::json!({ - "score": ev.score, - "time_seconds": ev.time_seconds, - }), - ); + client.event("Game", "Won", None, Some(ev.score as f64)); fire_flush(client.clone(), &settings.0); } } @@ -107,7 +100,7 @@ fn on_forfeit( return; }; for _ev in forfeits.read() { - client.record("game_forfeit", serde_json::json!({})); + client.event("Game", "Forfeit", None, None); fire_flush(client.clone(), &settings.0); } } @@ -121,19 +114,11 @@ fn on_new_game( return; }; for ev in requests.read() { - // Only record confirmed starts — skip the first unconfirmed request - // that spawns the "abandon game?" modal. if !ev.confirmed { continue; } - // mode = None means "reuse current game mode". Reading from - // GameStateResource at this point gives the still-active game's mode, - // which is exactly what the new game will inherit. let mode = ev.mode.unwrap_or(game.0.mode); - client.record( - "game_start", - serde_json::json!({ "mode": mode_str(mode) }), - ); + client.event("Game", "Start", Some(mode_str(mode)), None); } } @@ -145,10 +130,7 @@ fn on_achievement_unlocked( return; }; for ev in achievements.read() { - client.record( - "achievement_unlocked", - serde_json::json!({ "achievement_id": ev.0.id }), - ); + client.event("Achievement", "Unlocked", Some(&ev.0.id), None); } } @@ -170,30 +152,26 @@ fn tick_flush_timer( // Helpers // --------------------------------------------------------------------------- -fn client_for(settings: &Settings) -> Option> { +fn client_for(settings: &Settings) -> Option> { if !settings.analytics_enabled { return None; } - match &settings.sync_backend { - SyncBackend::SolitaireServer { url, .. } => { - Some(Arc::new(AnalyticsClient::new(url.clone()))) - } - SyncBackend::Local => None, - } -} - -fn fire_flush(client: Arc, settings: &Settings) { - let user_id = match &settings.sync_backend { + let url = settings.matomo_url.as_deref()?; + let uid = match &settings.sync_backend { SyncBackend::SolitaireServer { username, .. } => Some(username.clone()), SyncBackend::Local => None, }; + Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid))) +} + +fn fire_flush(client: Arc, _settings: &Settings) { AsyncComputeTaskPool::get() .spawn(async move { if let Ok(rt) = tokio::runtime::Builder::new_current_thread() .enable_all() .build() { - rt.block_on(client.flush(user_id)); + rt.block_on(client.flush()); } }) .detach(); diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin.rs index e738d61..0b4cdce 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin.rs @@ -1682,8 +1682,8 @@ fn spawn_settings_panel( } import_themes_row(body, font_res); - // --- Privacy (only shown when a sync server is configured) --- - if matches!(settings.sync_backend, SyncBackend::SolitaireServer { .. }) { + // --- Privacy (only shown when a Matomo URL is configured) --- + if settings.matomo_url.is_some() { section_label(body, "Privacy", font_res); toggle_row( body, @@ -1691,7 +1691,7 @@ fn spawn_settings_panel( AnalyticsEnabledText, on_off_label(settings.analytics_enabled), SettingsButton::ToggleAnalytics, - "Sends anonymous game events to your server. No personal data is collected.", + "Sends anonymous game events to Matomo for aggregate analytics.", font_res, ); } diff --git a/solitaire_server/src/analytics.rs b/solitaire_server/src/analytics.rs deleted file mode 100644 index 2bc30c7..0000000 --- a/solitaire_server/src/analytics.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! Analytics ingest endpoint. -//! -//! `POST /api/analytics` — accept a batch of game-play events from an -//! opted-in client. No authentication required; the endpoint is public -//! so events can be captured before the player logs in. -//! -//! Each event is validated individually — a bad event is skipped rather -//! than rejecting the whole batch. Duplicate event IDs are silently -//! ignored via `INSERT OR IGNORE` so clients may safely retry a failed -//! batch without creating duplicate rows. - -use axum::{extract::State, Json}; -use chrono::Utc; -use serde::Deserialize; -use serde_json::Value; -use uuid::Uuid; - -use crate::{error::AppError, AppState}; - -// --------------------------------------------------------------------------- -// Wire types -// --------------------------------------------------------------------------- - -/// Batch of events from a single client session. -#[derive(Debug, Deserialize)] -pub struct AnalyticsBatch { - /// UUID v4 generated once per app launch by the client. - pub session_id: String, - /// Optional username — populated when the player is logged in. - pub user_id: Option, - /// Events to ingest. Batches with more than 50 events are rejected. - pub events: Vec, -} - -/// One game-play event within a batch. -#[derive(Debug, Deserialize)] -pub struct AnalyticsEvent { - /// UUID v4 minted client-side. Used as idempotency key. - pub id: String, - /// Lowercase snake-case type, e.g. `"game_won"`. Max 64 chars. - pub event_type: String, - /// Event-specific JSON payload. - pub payload: Value, - /// ISO-8601 timestamp from the client clock. - pub client_time: String, -} - -// Validated, ready-to-insert form of an event. -struct ValidEvent { - id: String, - event_type: String, - payload_json: String, - client_time: String, -} - -// --------------------------------------------------------------------------- -// Handler -// --------------------------------------------------------------------------- - -/// `POST /api/analytics` — ingest a batch of analytics events. -pub async fn ingest( - State(state): State, - Json(batch): Json, -) -> Result, AppError> { - if batch.events.len() > 50 { - return Err(AppError::BadRequest("batch may contain at most 50 events".into())); - } - - let now = Utc::now(); - let received_at = now.to_rfc3339(); - // Reject events whose client_time claims to be more than 24 h in the future - // (clock skew protection; stale events from the past are fine). - let future_cutoff = now + chrono::Duration::hours(24); - - let valid: Vec = batch - .events - .iter() - .filter_map(|e| { - // Idempotency key must be a valid UUID. - if Uuid::parse_str(&e.id).is_err() { - return None; - } - // event_type: lowercase letters and underscores only, 1–64 chars. - if e.event_type.is_empty() - || e.event_type.len() > 64 - || !e.event_type.chars().all(|c| c.is_ascii_lowercase() || c == '_') - { - return None; - } - // client_time must parse and not be too far in the future. - let parsed = e.client_time.parse::>().ok()?; - if parsed > future_cutoff { - return None; - } - let payload_json = serde_json::to_string(&e.payload).ok()?; - Some(ValidEvent { - id: e.id.clone(), - event_type: e.event_type.clone(), - payload_json, - client_time: parsed.to_rfc3339(), - }) - }) - .collect(); - - if valid.is_empty() { - return Ok(Json(serde_json::json!({ "ok": true, "accepted": 0 }))); - } - - let mut tx = state.pool.begin().await?; - for ev in &valid { - sqlx::query!( - r#"INSERT OR IGNORE INTO analytics_events - (id, user_id, session_id, event_type, payload, client_time, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?)"#, - ev.id, - batch.user_id, - batch.session_id, - ev.event_type, - ev.payload_json, - ev.client_time, - received_at, - ) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - - let accepted = valid.len() as i64; - Ok(Json(serde_json::json!({ "ok": true, "accepted": accepted }))) -} diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 45a0e86..7102336 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -4,7 +4,6 @@ //! application against an in-memory SQLite database without starting a real //! TCP listener. -pub mod analytics; pub mod auth; pub mod challenge; pub mod error; @@ -190,23 +189,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { auth_routes }; - // Analytics endpoint — public, but throttled at 5 batches/min per IP to - // limit abuse. Rate limiting is skipped in tests (same pattern as auth). - let analytics_route = Router::new().route("/api/analytics", post(analytics::ingest)); - let analytics_route = if rate_limit { - let governor_conf = Arc::new( - GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) - .per_second(12) // 1 token / 12 s = 5 / min steady-state - .burst_size(5) - .finish() - .expect("invalid analytics governor config"), - ); - analytics_route.layer(GovernorLayer::new(governor_conf)) - } else { - analytics_route - }; - // Public endpoints (no auth, no rate limit beyond defaults). let public = Router::new() .route("/api/daily-challenge", get(challenge::daily_challenge)) @@ -251,7 +233,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { Router::new() .merge(protected) .merge(auth_routes) - .merge(analytics_route) .merge(public) .merge(web) // Reject request bodies larger than 1 MB. diff --git a/solitaire_server/web/game.html b/solitaire_server/web/game.html index 3919061..0e5519b 100644 --- a/solitaire_server/web/game.html +++ b/solitaire_server/web/game.html @@ -5,6 +5,20 @@ Ferrous Solitaire — Play + + +