use axum::{ extract::{Path, Query, State}, http::{header, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ collections::{BTreeMap, HashMap}, env, fs, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path as FsPath, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; use tokio::net::TcpListener; use tower_http::{ cors::{AllowOrigin, CorsLayer}, trace::TraceLayer, }; const MAX_TEAM_NAME_LENGTH: usize = 80; const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300); struct AppState { battles_db: PathBuf, teams_db: PathBuf, leaderboard_cache: Mutex>, } struct CachedLeaderboard { teams: Vec, } #[derive(Debug)] struct ApiError { status: StatusCode, message: String, } impl ApiError { fn bad_request(message: impl Into) -> Self { Self { status: StatusCode::BAD_REQUEST, message: message.into(), } } fn not_found(message: impl Into) -> Self { Self { status: StatusCode::NOT_FOUND, message: message.into(), } } fn internal(message: impl Into) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, message: message.into(), } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.status, Json(json!({ "error": self.message }))).into_response() } } type ApiResult = Result, ApiError>; #[derive(Deserialize)] struct LimitQuery { limit: Option, } #[derive(Deserialize)] struct ResolveQuery { name: String, } #[derive(Deserialize)] struct SearchQuery { q: Option, name: Option, limit: Option, } #[derive(Serialize)] struct HealthResponse { ok: bool, service: &'static str, battles_db: String, teams_db: String, databases: BTreeMap<&'static str, bool>, } #[derive(Clone, Serialize)] struct LeaderboardResponse { teams: Vec, } #[derive(Serialize)] struct RecentGamesResponse { matches: Vec, } #[derive(Serialize)] struct SearchResponse { teams: Vec, } #[derive(Serialize)] struct ResolveResponse { team_id: i64, name: String, } #[derive(Serialize)] struct TeamSearchRow { team_id: i64, name: String, members: i64, } #[derive(Clone, Serialize)] struct TeamLeaderboardRow { team_id: i64, name: String, player_count: i64, total_battles: i64, wins: i64, losses: i64, win_rate: f64, total_kills: i64, } #[derive(Serialize)] struct TeamDetail { team_id: i64, name: String, members: i64, captain_uid: Option, data_set: &'static str, team_summary: TeamSummary, players: Vec, } #[derive(Default, Serialize)] struct TeamSummary { player_count: i64, total_battles: i64, wins: i64, losses: i64, win_rate: f64, kdr: f64, total_kills: i64, total_points: i64, } #[derive(Serialize)] struct PlayerSummary { uid: String, nick: Option, role: String, total_battles: i64, wins: i64, losses: i64, win_rate: f64, total_kills: i64, ground_kills: i64, air_kills: i64, assists: i64, deaths: i64, kdr: f64, } #[derive(Serialize)] struct HistoryResponse { team_id: i64, name: String, history: Vec, } #[derive(Serialize)] struct PeriodHistory { period: String, battles: i64, wins: i64, losses: i64, win_rate: f64, } #[derive(Serialize)] struct GamesResponse { team_id: i64, name: String, games: Vec, } #[derive(Serialize)] struct GameResponse { game: GameRow, participants: Vec, } #[derive(Serialize)] struct GameRow { #[serde(skip_serializing_if = "Option::is_none")] team_name: Option, session_id: String, timestamp: i64, endtime_unix: i64, map_name: Option, mission_mode: Option, result: String, player_count: i64, winning_team: Option, losing_team: Option, stats: GameStats, } #[derive(Serialize)] struct GameStats { ground_kills: i64, air_kills: i64, assists: i64, captures: i64, deaths: i64, score: i64, missile_evades: i64, shell_interceptions: i64, team_kills_stat: i64, } #[derive(Serialize)] struct GameParticipant { team_name: String, result: String, player_count: i64, stats: GameStats, } #[derive(Serialize)] struct PlayerSearchResponse { players: Vec, } #[derive(Serialize)] struct PlayerRef { uid: String, nick: Option, } #[derive(Serialize)] struct PlayerProfile { uid: String, nick: Option, data_set: &'static str, career: PlayerCareer, teams: Vec, } #[derive(Serialize)] struct PlayerCareer { total_battles: i64, wins: i64, losses: i64, win_rate: f64, ground_kills: i64, air_kills: i64, total_kills: i64, assists: i64, captures: i64, deaths: i64, kdr: f64, } #[derive(Serialize)] struct PlayerTeamRef { team_id: Option, team_name: Option, games: i64, last_seen: i64, } struct TeamRecord { team_id: i64, name: String, members: i64, captain_uid: Option, } #[tokio::main] async fn main() -> Result<(), Box> { load_root_env(); tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let host = env_ip("BACKEND_HOST").unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)); let port = env_u16("BACKEND_PORT") .or_else(|| env_u16("PORT")) .unwrap_or(6000); let state = Arc::new(AppState { battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"), teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"), leaderboard_cache: Mutex::new(None), }); spawn_leaderboard_refresh(state.clone()); let app = Router::new() .route("/health", get(health)) .route("/api/tss/leaderboard/teams", get(leaderboard)) .route("/api/tss/games/recent", get(recent_games)) .route("/api/tss/games/{session_id}", get(game_detail)) .route("/api/tss/teams/resolve", get(resolve_team)) .route("/api/tss/teams/search", get(search_teams)) .route("/api/tss/teams/{team}", get(team_detail)) .route("/api/tss/teams/{team}/history", get(team_history)) .route("/api/tss/teams/{team}/games", get(team_games)) .route("/api/tss/players/resolve", get(resolve_player)) .route("/api/tss/players/search", get(search_players)) .route("/api/tss/player/{uid}", get(player_detail)) .layer( CorsLayer::new() .allow_methods([Method::GET]) .allow_origin(allowed_origins()) .allow_headers([header::ACCEPT, header::CONTENT_TYPE]), ) .layer(TraceLayer::new_for_http()) .with_state(state); let addr = SocketAddr::from((host, port)); let listener = TcpListener::bind(addr).await?; tracing::info!("tssbot backend listening on http://{}", addr); axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c() .await .expect("failed to install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .expect("failed to install signal handler") .recv() .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); tokio::select! { _ = ctrl_c => {}, _ = terminate => {}, } } async fn health(State(state): State>) -> Json { let mut databases = BTreeMap::new(); databases.insert( "battles", Connection::open_with_flags( &state.battles_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) .is_ok(), ); databases.insert( "teams", Connection::open_with_flags(&state.teams_db, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) .is_ok(), ); Json(HealthResponse { ok: databases.values().all(|ok| *ok), service: "tssbot-backend", battles_db: state.battles_db.display().to_string(), teams_db: state.teams_db.display().to_string(), databases, }) } async fn leaderboard( State(state): State>, Query(query): Query, ) -> ApiResult { let limit = usize::try_from(query.limit.unwrap_or(100).clamp(1, 100)).unwrap_or(100); if let Some(teams) = cached_leaderboard(&state, limit)? { return Ok(Json(LeaderboardResponse { teams })); } let teams = leaderboard_roster_rows(&state, limit)?; Ok(Json(LeaderboardResponse { teams })) } fn leaderboard_teams(state: &AppState, limit: usize) -> Result, ApiError> { let teams_conn = open_db(&state.teams_db)?; // Deduplicate teams by name across tournaments — pick the highest team_id // (most recent) per name for the roster count, but stats come from team_name. let mut stmt = teams_conn .prepare( "SELECT MAX(team_id), name, MAX(members), MAX(captain_uid) FROM teams_data GROUP BY name COLLATE NOCASE ORDER BY MAX(members) DESC, name COLLATE NOCASE ASC LIMIT ?1", ) .map_err(db_error)?; let teams = stmt .query_map(params![limit as i64], |row| read_team_record(row)) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(teams) } fn leaderboard_roster_rows( state: &AppState, limit: usize, ) -> Result, ApiError> { leaderboard_teams(state, limit).map(|teams| { teams .into_iter() .map(|team| TeamLeaderboardRow { team_id: team.team_id, name: team.name, player_count: team.members, total_battles: 0, wins: 0, losses: 0, win_rate: 0.0, total_kills: 0, }) .collect() }) } fn leaderboard_rows(state: &AppState, limit: usize) -> Result, ApiError> { let battles_conn = open_db(&state.battles_db)?; let teams = leaderboard_teams(state, limit)?; let team_names = teams .iter() .map(|team| team.name.as_str()) .collect::>(); let mut summaries = team_summaries_for(&battles_conn, &team_names)?; let mut rows = Vec::with_capacity(teams.len()); for team in teams { let summary = summaries .remove(&team.name.to_ascii_lowercase()) .unwrap_or_default(); rows.push(TeamLeaderboardRow { team_id: team.team_id, name: team.name, player_count: team.members, total_battles: summary.total_battles, wins: summary.wins, losses: summary.losses, win_rate: summary.win_rate, total_kills: summary.total_kills, }); } Ok(rows) } fn spawn_leaderboard_refresh(state: Arc) { tokio::spawn(async move { loop { let refresh_state = state.clone(); match tokio::task::spawn_blocking(move || leaderboard_rows(&refresh_state, 100)).await { Ok(Ok(rows)) => { if let Err(error) = cache_leaderboard(&state, &rows) { tracing::warn!("could not cache leaderboard refresh: {}", error.message); } } Ok(Err(error)) => { tracing::warn!("could not refresh leaderboard cache: {}", error.message); } Err(error) => { tracing::warn!("leaderboard refresh task failed: {}", error); } } tokio::time::sleep(LEADERBOARD_CACHE_TTL).await; } }); } async fn recent_games( State(state): State>, Query(query): Query, ) -> ApiResult { let limit = i64::from(query.limit.unwrap_or(50).clamp(1, 100)); let battles_conn = open_db(&state.battles_db)?; let matches = recent_games_for(&battles_conn, limit)?; Ok(Json(RecentGamesResponse { matches })) } async fn game_detail( State(state): State>, Path(session_id): Path, ) -> ApiResult { let session_id = validate_session_id(&session_id)?; let battles_conn = open_db(&state.battles_db)?; let game = game_for(&battles_conn, session_id)? .ok_or_else(|| ApiError::not_found("Game not found"))?; let participants = game_participants_for(&battles_conn, session_id)?; Ok(Json(GameResponse { game, participants })) } async fn resolve_team( State(state): State>, Query(query): Query, ) -> ApiResult { let name = validate_team_name(&query.name)?; let conn = open_db(&state.teams_db)?; let team = find_team(&conn, name)?.ok_or_else(|| ApiError::not_found("Team not found"))?; Ok(Json(ResolveResponse { team_id: team.team_id, name: team.name, })) } async fn search_teams( State(state): State>, Query(query): Query, ) -> ApiResult { let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or(""); let name = validate_team_name(raw)?; let limit = i64::from(query.limit.unwrap_or(10).clamp(1, 20)); let like = format!("%{}%", escape_like(name)); let conn = open_db(&state.teams_db)?; let mut stmt = conn .prepare( "SELECT team_id, name, members FROM teams_data WHERE name LIKE ?1 ESCAPE '\\' ORDER BY CASE WHEN name = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, members DESC, name COLLATE NOCASE ASC LIMIT ?3", ) .map_err(db_error)?; let teams = stmt .query_map(params![like, name, limit], |row| { Ok(TeamSearchRow { team_id: row.get(0)?, name: row.get(1)?, members: row.get(2)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(Json(SearchResponse { teams })) } async fn team_detail( State(state): State>, Path(team_name): Path, ) -> ApiResult { let decoded = decode_path_team(&team_name)?; let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; let team = find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; let summary = team_summary_for(&battles_conn, &team.name)?; // team_id is the most recent tournament entry — used only for the roster lookup. let players = player_summaries_for(&teams_conn, &battles_conn, team.team_id, &team.name)?; Ok(Json(TeamDetail { team_id: team.team_id, name: team.name, members: team.members, captain_uid: team.captain_uid, data_set: "tss", team_summary: summary, players, })) } async fn team_history( State(state): State>, Path(team_name): Path, ) -> ApiResult { let decoded = decode_path_team(&team_name)?; let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; let team = find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; let history = period_history_for(&battles_conn, &team.name)?; Ok(Json(HistoryResponse { team_id: team.team_id, name: team.name, history, })) } async fn team_games( State(state): State>, Path(team_name): Path, ) -> ApiResult { let decoded = decode_path_team(&team_name)?; let teams_conn = open_db(&state.teams_db)?; let battles_conn = open_db(&state.battles_db)?; let team = find_team(&teams_conn, &decoded)?.ok_or_else(|| ApiError::not_found("Team not found"))?; let games = games_for(&battles_conn, &team.name)?; Ok(Json(GamesResponse { team_id: team.team_id, name: team.name, games, })) } async fn resolve_player( State(state): State>, Query(query): Query, ) -> ApiResult { let name = validate_player_name(&query.name)?; let conn = open_db(&state.battles_db)?; let players = player_resolve(&conn, name)?; if players.is_empty() { return Err(ApiError::not_found("Player not found")); } Ok(Json(PlayerSearchResponse { players })) } async fn search_players( State(state): State>, Query(query): Query, ) -> ApiResult { let raw = query.q.as_deref().or(query.name.as_deref()).unwrap_or(""); let name = validate_player_name(raw)?; let limit = i64::from(query.limit.unwrap_or(25).clamp(1, 25)); let conn = open_db(&state.battles_db)?; let players = player_search(&conn, name, limit)?; Ok(Json(PlayerSearchResponse { players })) } async fn player_detail( State(state): State>, Path(uid): Path, ) -> ApiResult { let uid = validate_uid(&uid)?; let conn = open_db(&state.battles_db)?; let career = player_career_for(&conn, &uid)?.ok_or_else(|| ApiError::not_found("Player not found"))?; let nick = latest_nick_for(&conn, &uid)?; let teams = player_teams_for(&conn, &uid)?; Ok(Json(PlayerProfile { uid, nick, data_set: "tss", career, teams, })) } fn open_db(path: &FsPath) -> Result { Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).map_err(|error| { ApiError::internal(format!("Could not open {}: {}", path.display(), error)) }) } fn db_error(error: rusqlite::Error) -> ApiError { ApiError::internal(format!("Database query failed: {}", error)) } fn read_team_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(TeamRecord { team_id: row.get(0)?, name: row.get(1)?, members: row.get(2)?, captain_uid: row.get(3)?, }) } fn find_team(conn: &Connection, name: &str) -> Result, ApiError> { // Return the most recent tournament entry for this team (highest team_id). conn.query_row( "SELECT team_id, name, members, captain_uid FROM teams_data WHERE name = ?2 COLLATE NOCASE OR team_id = ?1 ORDER BY team_id DESC LIMIT 1", params![name.parse::().ok(), name], read_team_record, ) .optional() .map_err(db_error) } fn team_summary_for(conn: &Connection, team_name: &str) -> Result { conn.query_row( "SELECT COUNT(DISTINCT session_id), SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END), SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END), COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(score), 0), COUNT(DISTINCT UID) FROM player_games_hist WHERE team_name = ?1 COLLATE NOCASE", params![team_name], |row| { let battles: i64 = row.get(0)?; let wins: i64 = row.get(1)?; let losses: i64 = row.get(2)?; let ground: i64 = row.get(3)?; let air: i64 = row.get(4)?; let assists: i64 = row.get(5)?; let deaths: i64 = row.get(6)?; let score: i64 = row.get(7)?; let player_count: i64 = row.get(8)?; let total_kills = ground + air; Ok(TeamSummary { player_count, total_battles: battles, wins, losses, win_rate: percent(wins, battles), kdr: ratio(total_kills, deaths), total_kills, total_points: score + assists + total_kills, }) }, ) .map_err(db_error) } fn team_summaries_for( conn: &Connection, team_names: &[&str], ) -> Result, ApiError> { if team_names.is_empty() { return Ok(HashMap::new()); } let placeholders = std::iter::repeat("?") .take(team_names.len()) .collect::>() .join(", "); let sql = format!( "SELECT team_name, COUNT(DISTINCT session_id), SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END), SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END), COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), COALESCE(SUM(score), 0), COUNT(DISTINCT UID) FROM player_games_hist WHERE team_name COLLATE NOCASE IN ({placeholders}) GROUP BY team_name COLLATE NOCASE" ); let mut stmt = conn.prepare(&sql).map_err(db_error)?; let summaries = stmt .query_map(rusqlite::params_from_iter(team_names.iter()), |row| { let name: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; let losses: i64 = row.get(3)?; let ground: i64 = row.get(4)?; let air: i64 = row.get(5)?; let assists: i64 = row.get(6)?; let deaths: i64 = row.get(7)?; let score: i64 = row.get(8)?; let player_count: i64 = row.get(9)?; let total_kills = ground + air; Ok(( name.to_ascii_lowercase(), TeamSummary { player_count, total_battles: battles, wins, losses, win_rate: percent(wins, battles), kdr: ratio(total_kills, deaths), total_kills, total_points: score + assists + total_kills, }, )) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(summaries) } fn cached_leaderboard( state: &AppState, limit: usize, ) -> Result>, ApiError> { let cache = state .leaderboard_cache .lock() .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; Ok(cache.as_ref().and_then(|cached| { (cached.teams.len() >= limit).then(|| cached.teams.iter().take(limit).cloned().collect()) })) } fn cache_leaderboard(state: &AppState, teams: &[TeamLeaderboardRow]) -> Result<(), ApiError> { let mut cache = state .leaderboard_cache .lock() .map_err(|_| ApiError::internal("Leaderboard cache lock failed"))?; *cache = Some(CachedLeaderboard { teams: teams.to_vec(), }); Ok(()) } fn player_summaries_for( teams_conn: &Connection, battles_conn: &Connection, // team_id: roster for this specific tournament entry only team_id: i64, // team_name: cross-tournament stats key team_name: &str, ) -> Result, ApiError> { let mut stmt = teams_conn .prepare( "SELECT uid, role FROM team_members WHERE team_id = ?1 ORDER BY uid", ) .map_err(db_error)?; let members = stmt .query_map(params![team_id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; let mut stats_stmt = battles_conn .prepare( "SELECT UID, COUNT(DISTINCT session_id), SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END), SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END), COALESCE(SUM(ground_kills), 0), COALESCE(SUM(air_kills), 0), COALESCE(SUM(assists), 0), COALESCE(SUM(deaths), 0), (SELECT nick FROM player_games_hist WHERE UID = p.UID AND nick IS NOT NULL ORDER BY endtime_unix DESC LIMIT 1) FROM player_games_hist p WHERE team_name = ?1 COLLATE NOCASE GROUP BY UID", ) .map_err(db_error)?; let mut summaries = stats_stmt .query_map(params![team_name], |row| { let uid: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; let losses: i64 = row.get(3)?; let ground: i64 = row.get(4)?; let air: i64 = row.get(5)?; let assists: i64 = row.get(6)?; let deaths: i64 = row.get(7)?; let nick: Option = row.get(8)?; let total_kills = ground + air; Ok(( uid.clone(), PlayerSummary { uid, nick, role: String::new(), total_battles: battles, wins, losses, win_rate: percent(wins, battles), total_kills, ground_kills: ground, air_kills: air, assists, deaths, kdr: ratio(total_kills, deaths), }, )) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; let mut out = Vec::with_capacity(members.len()); for (uid, role) in members { let mut summary = summaries.remove(&uid).unwrap_or(PlayerSummary { uid, nick: None, role: String::new(), total_battles: 0, wins: 0, losses: 0, win_rate: 0.0, total_kills: 0, ground_kills: 0, air_kills: 0, assists: 0, deaths: 0, kdr: 0.0, }); summary.role = role; out.push(summary); } out.sort_by(|a, b| b.total_battles.cmp(&a.total_battles)); Ok(out) } fn player_search(conn: &Connection, query: &str, limit: i64) -> Result, ApiError> { let like = format!("%{}%", escape_like(query)); let mut stmt = conn .prepare( "SELECT UID, MIN(nick) AS nick, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE nick LIKE ?1 ESCAPE '\\' COLLATE NOCASE GROUP BY UID ORDER BY CASE WHEN MIN(nick) = ?2 COLLATE NOCASE THEN 0 ELSE 1 END, last_seen DESC LIMIT ?3", ) .map_err(db_error)?; let players = stmt .query_map(params![like, query, limit], |row| { Ok(PlayerRef { uid: row.get(0)?, nick: row.get(1)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(players) } fn player_resolve(conn: &Connection, name: &str) -> Result, ApiError> { // Exact nick match first; fall back to substring search. let mut stmt = conn .prepare( "SELECT UID, MIN(nick) AS nick FROM player_games_hist WHERE nick = ?1 COLLATE NOCASE GROUP BY UID ORDER BY nick LIMIT 25", ) .map_err(db_error)?; let exact = stmt .query_map(params![name], |row| { Ok(PlayerRef { uid: row.get(0)?, nick: row.get(1)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; if !exact.is_empty() { return Ok(exact); } player_search(conn, name, 25) } fn latest_nick_for(conn: &Connection, uid: &str) -> Result, ApiError> { conn.query_row( "SELECT nick FROM player_games_hist WHERE UID = ?1 ORDER BY endtime_unix DESC LIMIT 1", params![uid], |row| row.get::<_, Option>(0), ) .optional() .map_err(db_error) .map(|opt| opt.flatten()) } fn player_career_for(conn: &Connection, uid: &str) -> Result, ApiError> { // Player totals repeat across a player's per-vehicle rows for a session, so // collapse to one value per session (MAX) before summing. conn.query_row( "SELECT COUNT(*) AS battles, COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins, COALESCE(SUM(CASE WHEN UPPER(victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses, COALESCE(SUM(gk), 0), COALESCE(SUM(ak), 0), COALESCE(SUM(asi), 0), COALESCE(SUM(cap), 0), COALESCE(SUM(de), 0) FROM ( SELECT session_id, MAX(victor_bool) AS victor_bool, MAX(ground_kills) AS gk, MAX(air_kills) AS ak, MAX(assists) AS asi, MAX(captures) AS cap, MAX(deaths) AS de FROM player_games_hist WHERE UID = ?1 GROUP BY session_id )", params![uid], |row| { let battles: i64 = row.get(0)?; let wins: i64 = row.get(1)?; let losses: i64 = row.get(2)?; let ground: i64 = row.get(3)?; let air: i64 = row.get(4)?; let assists: i64 = row.get(5)?; let captures: i64 = row.get(6)?; let deaths: i64 = row.get(7)?; let total_kills = ground + air; Ok(( battles, wins, losses, ground, air, assists, captures, deaths, total_kills, )) }, ) .optional() .map_err(db_error) .map(|opt| { opt.and_then( |(battles, wins, losses, ground, air, assists, captures, deaths, total_kills)| { if battles == 0 { None } else { Some(PlayerCareer { total_battles: battles, wins, losses, win_rate: percent(wins, battles), ground_kills: ground, air_kills: air, total_kills, assists, captures, deaths, kdr: ratio(total_kills, deaths), }) } }, ) }) } fn player_teams_for(conn: &Connection, uid: &str) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT team_id, MAX(team_name) AS team_name, COUNT(DISTINCT session_id) AS games, MAX(endtime_unix) AS last_seen FROM player_games_hist WHERE UID = ?1 AND team_id IS NOT NULL GROUP BY team_id ORDER BY last_seen DESC", ) .map_err(db_error)?; let teams = stmt .query_map(params![uid], |row| { Ok(PlayerTeamRef { team_id: row.get(0)?, team_name: row.get(1)?, games: row.get(2)?, last_seen: row.get(3)?, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(teams) } fn period_history_for(conn: &Connection, team_name: &str) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT strftime('%Y-%m', endtime_unix, 'unixepoch') AS period, COUNT(DISTINCT session_id), SUM(CASE WHEN victor_bool = 'Win' THEN 1 ELSE 0 END), SUM(CASE WHEN victor_bool != 'Win' THEN 1 ELSE 0 END) FROM player_games_hist WHERE team_name = ?1 COLLATE NOCASE AND endtime_unix > 0 GROUP BY period ORDER BY period ASC", ) .map_err(db_error)?; let rows = stmt .query_map(params![team_name], |row| { let period: String = row.get(0)?; let battles: i64 = row.get(1)?; let wins: i64 = row.get(2)?; let losses: i64 = row.get(3)?; Ok(PeriodHistory { period, battles, wins, losses, win_rate: percent(wins, battles), }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(rows) } fn games_for(conn: &Connection, team_name: &str) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT p.session_id, COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp, m.mission_name, m.mission_mode, CASE WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win' ELSE 'Loss' END AS result, COUNT(DISTINCT p.UID), COALESCE(SUM(p.ground_kills), 0), COALESCE(SUM(p.air_kills), 0), COALESCE(SUM(p.assists), 0), COALESCE(SUM(p.captures), 0), COALESCE(SUM(p.deaths), 0), COALESCE(SUM(p.score), 0), COALESCE(SUM(p.missile_evades), 0), COALESCE(SUM(p.shell_interceptions), 0), COALESCE(SUM(p.team_kills_stat), 0), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = p.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = p.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Loss' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1) FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE p.team_name = ?1 COLLATE NOCASE GROUP BY p.session_id ORDER BY timestamp DESC LIMIT 100", ) .map_err(db_error)?; let rows = stmt .query_map(params![team_name], |row| { let session_id: String = row.get(0)?; let timestamp: i64 = row.get(1)?; let map_name: Option = row.get(2)?; let mission_mode: Option = row.get(3)?; Ok(GameRow { team_name: None, session_id, timestamp, endtime_unix: timestamp, map_name, mission_mode, result: row.get(4)?, player_count: row.get(5)?, winning_team: row.get(15)?, losing_team: row.get(16)?, stats: GameStats { ground_kills: row.get(6)?, air_kills: row.get(7)?, assists: row.get(8)?, captures: row.get(9)?, deaths: row.get(10)?, score: row.get(11)?, missile_evades: row.get(12)?, shell_interceptions: row.get(13)?, team_kills_stat: row.get(14)?, }, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(rows) } fn recent_games_for(conn: &Connection, limit: i64) -> Result, ApiError> { let mut stmt = conn .prepare( "WITH recent AS ( SELECT team_name, session_id, MAX(endtime_unix) AS timestamp FROM player_games_hist WHERE team_name IS NOT NULL AND team_name != '' GROUP BY session_id ORDER BY timestamp DESC LIMIT ?1 ) SELECT r.team_name, r.session_id, COALESCE(m.endtime_unix, r.timestamp, 0) AS timestamp, m.mission_name, m.mission_mode, CASE WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win' ELSE 'Loss' END AS result, COUNT(DISTINCT p.UID), COALESCE(SUM(p.ground_kills), 0), COALESCE(SUM(p.air_kills), 0), COALESCE(SUM(p.assists), 0), COALESCE(SUM(p.captures), 0), COALESCE(SUM(p.deaths), 0), COALESCE(SUM(p.score), 0), COALESCE(SUM(p.missile_evades), 0), COALESCE(SUM(p.shell_interceptions), 0), COALESCE(SUM(p.team_kills_stat), 0), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = r.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = r.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Loss' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1) FROM recent r JOIN player_games_hist p ON p.session_id = r.session_id AND p.team_name = r.team_name COLLATE NOCASE LEFT JOIN match_summary m ON m.session_id = r.session_id GROUP BY r.team_name COLLATE NOCASE, r.session_id ORDER BY timestamp DESC LIMIT ?1", ) .map_err(db_error)?; let rows = stmt .query_map(params![limit], |row| { let timestamp: i64 = row.get(2)?; Ok(GameRow { team_name: row.get(0)?, session_id: row.get(1)?, timestamp, endtime_unix: timestamp, map_name: row.get(3)?, mission_mode: row.get(4)?, result: row.get(5)?, player_count: row.get(6)?, winning_team: row.get(16)?, losing_team: row.get(17)?, stats: GameStats { ground_kills: row.get(7)?, air_kills: row.get(8)?, assists: row.get(9)?, captures: row.get(10)?, deaths: row.get(11)?, score: row.get(12)?, missile_evades: row.get(13)?, shell_interceptions: row.get(14)?, team_kills_stat: row.get(15)?, }, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(rows) } fn game_for(conn: &Connection, session_id: &str) -> Result, ApiError> { conn.query_row( "SELECT p.session_id, COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp, m.mission_name, m.mission_mode, COUNT(DISTINCT p.UID), COALESCE(SUM(p.ground_kills), 0), COALESCE(SUM(p.air_kills), 0), COALESCE(SUM(p.assists), 0), COALESCE(SUM(p.captures), 0), COALESCE(SUM(p.deaths), 0), COALESCE(SUM(p.score), 0), COALESCE(SUM(p.missile_evades), 0), COALESCE(SUM(p.shell_interceptions), 0), COALESCE(SUM(p.team_kills_stat), 0), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = p.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Win' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1), (SELECT pg.team_name FROM player_games_hist pg WHERE pg.session_id = p.session_id AND pg.team_name IS NOT NULL AND pg.team_name != '' AND pg.victor_bool = 'Loss' GROUP BY pg.team_name COLLATE NOCASE ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE LIMIT 1) FROM player_games_hist p LEFT JOIN match_summary m ON m.session_id = p.session_id WHERE p.session_id = ?1 GROUP BY p.session_id", params![session_id], |row| { let timestamp: i64 = row.get(1)?; Ok(GameRow { team_name: None, session_id: row.get(0)?, timestamp, endtime_unix: timestamp, map_name: row.get(2)?, mission_mode: row.get(3)?, result: String::new(), player_count: row.get(4)?, winning_team: row.get(14)?, losing_team: row.get(15)?, stats: GameStats { ground_kills: row.get(5)?, air_kills: row.get(6)?, assists: row.get(7)?, captures: row.get(8)?, deaths: row.get(9)?, score: row.get(10)?, missile_evades: row.get(11)?, shell_interceptions: row.get(12)?, team_kills_stat: row.get(13)?, }, }) }, ) .optional() .map_err(db_error) } fn game_participants_for( conn: &Connection, session_id: &str, ) -> Result, ApiError> { let mut stmt = conn .prepare( "SELECT p.team_name, CASE WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win' ELSE 'Loss' END AS result, COUNT(DISTINCT p.UID), COALESCE(SUM(p.ground_kills), 0), COALESCE(SUM(p.air_kills), 0), COALESCE(SUM(p.assists), 0), COALESCE(SUM(p.captures), 0), COALESCE(SUM(p.deaths), 0), COALESCE(SUM(p.score), 0), COALESCE(SUM(p.missile_evades), 0), COALESCE(SUM(p.shell_interceptions), 0), COALESCE(SUM(p.team_kills_stat), 0) FROM player_games_hist p WHERE p.session_id = ?1 AND p.team_name IS NOT NULL AND p.team_name != '' GROUP BY p.team_name COLLATE NOCASE ORDER BY CASE WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 0 ELSE 1 END, p.team_name COLLATE NOCASE", ) .map_err(db_error)?; let rows = stmt .query_map(params![session_id], |row| { Ok(GameParticipant { team_name: row.get(0)?, result: row.get(1)?, player_count: row.get(2)?, stats: GameStats { ground_kills: row.get(3)?, air_kills: row.get(4)?, assists: row.get(5)?, captures: row.get(6)?, deaths: row.get(7)?, score: row.get(8)?, missile_evades: row.get(9)?, shell_interceptions: row.get(10)?, team_kills_stat: row.get(11)?, }, }) }) .map_err(db_error)? .collect::, _>>() .map_err(db_error)?; Ok(rows) } fn validate_team_name(name: &str) -> Result<&str, ApiError> { let trimmed = name.trim(); if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { return Err(ApiError::bad_request( "Team name must be 2 to 80 characters", )); } Ok(trimmed) } fn validate_player_name(name: &str) -> Result<&str, ApiError> { let trimmed = name.trim(); if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH { return Err(ApiError::bad_request( "Player name must be 2 to 80 characters", )); } Ok(trimmed) } fn validate_uid(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() || trimmed.len() > 32 || !trimmed.chars().all(|c| c.is_ascii_digit()) { return Err(ApiError::bad_request("Invalid player UID")); } Ok(trimmed.to_string()) } fn validate_session_id(value: &str) -> Result<&str, ApiError> { if value.is_empty() || value.len() > 96 || !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) { return Err(ApiError::bad_request("Invalid game ID")); } Ok(value) } fn decode_path_team(value: &str) -> Result { let decoded = urlencoding::decode(value) .map_err(|_| ApiError::bad_request("Invalid team name"))? .into_owned(); validate_team_name(&decoded)?; Ok(decoded) } fn escape_like(value: &str) -> String { value .replace('\\', "\\\\") .replace('%', "\\%") .replace('_', "\\_") } fn percent(part: i64, total: i64) -> f64 { if total <= 0 { 0.0 } else { (part as f64 / total as f64) * 100.0 } } fn ratio(top: i64, bottom: i64) -> f64 { if bottom <= 0 { top as f64 } else { top as f64 / bottom as f64 } } fn env_u16(key: &str) -> Option { env::var(key).ok()?.parse().ok() } fn env_ip(key: &str) -> Option { env::var(key).ok()?.parse().ok() } fn allowed_origins() -> AllowOrigin { let origins = env::var("BACKEND_ALLOWED_ORIGINS") .or_else(|_| env::var("PUBLIC_ORIGIN")) .unwrap_or_default() .split(',') .filter_map(|origin| { let origin = origin.trim(); if origin.is_empty() { return None; } HeaderValue::from_str(origin).ok() }) .collect::>(); if origins.is_empty() { AllowOrigin::list(Vec::::new()) } else { AllowOrigin::list(origins) } } fn resolve_db_path(env_key: &str, default_file: &str) -> PathBuf { let raw = env::var(env_key).unwrap_or_else(|_| default_file.to_string()); let expanded = expand_home(&raw); let path = PathBuf::from(expanded); if path.is_absolute() { path } else { env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(path) } } fn expand_home(value: &str) -> String { if value == "~" { return home_dir() .map(|p| p.display().to_string()) .unwrap_or_else(|| value.to_string()); } if let Some(rest) = value .strip_prefix("~/") .or_else(|| value.strip_prefix("~\\")) { if let Some(home) = home_dir() { return home.join(rest).display().to_string(); } } value.to_string() } fn home_dir() -> Option { env::var_os("HOME") .or_else(|| env::var_os("USERPROFILE")) .map(PathBuf::from) } fn load_root_env() { let candidates = [ env::current_dir().ok().map(|path| path.join(".env")), env::current_dir() .ok() .and_then(|path| path.parent().map(|parent| parent.join(".env"))), Some( PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join(".env"), ), ]; for candidate in candidates.into_iter().flatten() { if candidate.exists() { load_env_file(&candidate); return; } } } fn load_env_file(path: &FsPath) { let Ok(contents) = fs::read_to_string(path) else { return; }; for line in contents.lines() { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } let Some((key, value)) = trimmed.split_once('=') else { continue; }; let key = key.trim(); if key.is_empty() || env::var_os(key).is_some() { continue; } let value = value .trim() .trim_matches('"') .trim_matches('\'') .to_string(); env::set_var(key, value); } }