mod auth; mod config; mod quota; mod steam; use std::{ collections::HashMap, sync::Arc, time::{Duration, Instant}, }; use anyhow::Context; use axum::{ extract::{Path, State}, http::{HeaderName, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, routing::get, Json, Router, }; use tokio::sync::Mutex; use tower_http::cors::{AllowOrigin, CorsLayer}; use auth::ApiToken; use config::Config; use quota::{Quota, QuotaStatus}; use steam::OwnedGames; const CACHE_TTL: Duration = Duration::from_secs(5 * 60); #[derive(Clone)] pub struct AppState { config: Arc, quota: Arc, cache: Arc>>, } #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); let config_path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string()); let config = Config::from_file(&config_path)?; let bind_addr = config.server.bind_addr.clone(); let quota = Quota::load(config.quota.state_file.clone(), config.steam.daily_limit)?; let cors = build_cors_layer(&config.cors.allowed_origins)?; let state = AppState { config: Arc::new(config), quota: Arc::new(quota), cache: Arc::new(Mutex::new(HashMap::new())), }; let app = Router::new() .route("/owned-games/:steamid", get(owned_games)) .route("/healthz", get(healthz)) .layer(cors) .with_state(state); let listener = tokio::net::TcpListener::bind(&bind_addr).await?; tracing::info!("listening on {bind_addr}"); axum::serve(listener, app).await?; Ok(()) } async fn healthz() -> &'static str { "ok" } fn build_cors_layer(allowed_origins: &[String]) -> anyhow::Result { let origins = allowed_origins .iter() .map(|o| { o.parse::() .with_context(|| format!("cors.allowed_origins entry {o:?} is not a valid Origin value")) }) .collect::>>()?; Ok(CorsLayer::new() .allow_origin(AllowOrigin::list(origins)) .allow_methods([Method::GET]) .allow_headers([HeaderName::from_static("x-api-token")])) } enum ApiError { BadSteamId, QuotaExceeded { retry_after_secs: i64 }, Upstream, } impl IntoResponse for ApiError { fn into_response(self) -> Response { match self { ApiError::BadSteamId => ( StatusCode::BAD_REQUEST, "steamid must be a 17-digit SteamID64", ) .into_response(), ApiError::QuotaExceeded { retry_after_secs } => { let mut resp = ( StatusCode::TOO_MANY_REQUESTS, "daily Steam API call quota exhausted, try again after UTC midnight", ) .into_response(); if let Ok(value) = retry_after_secs.to_string().parse() { resp.headers_mut().insert("Retry-After", value); } resp } ApiError::Upstream => ( StatusCode::BAD_GATEWAY, "failed to fetch data from the Steam Web API", ) .into_response(), } } } fn is_valid_steamid(steamid: &str) -> bool { steamid.len() == 17 && steamid.bytes().all(|b| b.is_ascii_digit()) } async fn owned_games( State(state): State, Path(steamid): Path, _token: ApiToken, ) -> Result, ApiError> { if !is_valid_steamid(&steamid) { return Err(ApiError::BadSteamId); } if let Some((fetched_at, games)) = state.cache.lock().await.get(&steamid) { if fetched_at.elapsed() < CACHE_TTL { return Ok(Json(games.clone())); } } match state.quota.check().await { QuotaStatus::Exceeded { retry_after_secs } => { return Err(ApiError::QuotaExceeded { retry_after_secs }) } QuotaStatus::Ok => {} } let result = steam::fetch_owned_games( &state.config.steam.api_key, &state.config.steam.user_agent, &steamid, ) .await; // Count the call against quota regardless of whether Steam returned an // error, since it still consumed one of our daily API calls. if let Err(e) = state.quota.record_call().await { tracing::error!("failed to persist quota state: {e:#}"); } let games = result.map_err(|e| { tracing::warn!("steam api call failed: {e:#}"); ApiError::Upstream })?; state .cache .lock() .await .insert(steamid, (Instant::now(), games.clone())); Ok(Json(games)) }