Something went wrong. Try again.
currently proxies requests for owned games, since that is hidden behind a steam developer key and not entirely public
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172mod 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<Config>, quota: Arc<Quota>, cache: Arc<Mutex<HashMap<String, (Instant, OwnedGames)>>>,}
#[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<CorsLayer> { let origins = allowed_origins .iter() .map(|o| { o.parse::<HeaderValue>() .with_context(|| format!("cors.allowed_origins entry {o:?} is not a valid Origin value")) }) .collect::<anyhow::Result<Vec<_>>>()?;
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<AppState>, Path(steamid): Path<String>, _token: ApiToken,) -> Result<Json<OwnedGames>, 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))}