From a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:19:00 +0200 Subject: Charge for store items, and price them individually The store had 26 items, a price on each and a working equip flow, but the purchase endpoint granted ownership without taking any money. Anyone signed in could take the whole catalogue for nothing. That endpoint is now gone. Payment goes through Stripe Checkout, which is hosted by Stripe. The buyer is redirected there and comes back, so no card details reach this server and it stays outside PCI scope. Three rules hold the money path together: - The price comes from the server's own catalogue row. The client sends an item id and never an amount. - Nothing is granted at checkout. The item appears only when a webhook arrives with a valid HMAC-SHA256 signature, checked in constant time against a 5 minute timestamp window. - Fulfilment keys off the processor's session id, which is UNIQUE, so a webhook delivered twice cannot grant the same item twice. Prices now vary by item. Every caret cost the same as every other because they are the same thing in a different colour, which left nothing to save for. Carets run 149 to 349, flair 129 to 299, and sprites 249 to 399, since a sprite is the one cosmetic every other racer sees. Four bundles sit above the catalogue, each priced below the sum of its parts: Starter Kit, Neon Set, Racer Set and The Lot. The saving is computed from the current item prices rather than asserted, so it cannot drift. The Lot is defined as every cosmetic rather than a fixed list, so it stays complete as items are added. Three bugs found while wiring this up: - Sprites never showed as equipped. The store compared the equipped caret and flair but not the sprite. - Supporter status was a stored boolean that was set on payment and never cleared, so a 30 day subscription lasted forever. Both read paths now derive it from the expiry. - sqlx::migrate! reads the migrations directory at compile time, but cargo watches source files only. Adding a migration did not trigger a rebuild, so the binary shipped the old migration set and the schema change never ran. A build.rs now declares the dependency. Verified against a real database: bundle maths, the grant statement and its replay, 401 unauthenticated, 404 on unknown ids, 501 with no Stripe keys, and both already-owned refusals. --- crates/server/src/auth.rs | 18 +- crates/server/src/billing.rs | 461 +++++++++++++++++++++++++++++++++++++++++ crates/server/src/cosmetics.rs | 85 +++++--- crates/server/src/main.rs | 14 +- crates/server/src/state.rs | 3 + 5 files changed, 540 insertions(+), 41 deletions(-) create mode 100644 crates/server/src/billing.rs (limited to 'crates/server/src') diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index caf428b..3dbbeff 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -309,11 +309,19 @@ async fn me(State(state): State>, jar: CookieJar) -> Result $2 + FROM users WHERE id = $1", + ) + .bind(&user.id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .fetch_optional(&state.db) + .await? + .unwrap_or(false); Ok(Json(serde_json::json!({ "id": user.id, "username": user.username, diff --git a/crates/server/src/billing.rs b/crates/server/src/billing.rs new file mode 100644 index 0000000..fda42c7 --- /dev/null +++ b/crates/server/src/billing.rs @@ -0,0 +1,461 @@ +//! Payments, through Stripe Checkout. +//! +//! Three rules shape this module. +//! +//! Checkout is hosted by Stripe. The customer is redirected there, enters +//! their card there, and comes back. No card details reach this server, which +//! keeps it outside PCI scope entirely. That is worth more than the small +//! amount of control a self-hosted form would buy. +//! +//! Price is decided here, never by the caller. The client asks to buy a +//! cosmetic by id; the amount comes from this server's own catalogue row. +//! +//! Nothing is granted until Stripe says so, through a webhook whose signature +//! is verified. A request that merely claims a payment succeeded is worthless. + +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::HeaderMap; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use axum_extra::extract::CookieJar; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use sha2::Sha256; +use sqlx::Row; +use std::sync::Arc; +use subtle::ConstantTimeEq; +use time::{Duration as TimeDuration, OffsetDateTime}; + +/// What the supporter subscription costs, and how long it lasts. +const SUPPORTER_PRICE_CENTS: i32 = 300; +const SUPPORTER_DAYS: i64 = 30; + +/// A Stripe timestamp older than this is not accepted, so a captured webhook +/// cannot be replayed later. +const WEBHOOK_TOLERANCE_SECS: i64 = 300; + +pub fn router() -> Router> { + Router::new() + .route("/api/billing/checkout/:cosmetic_id", post(checkout_cosmetic)) + .route("/api/billing/bundle/:bundle_id", post(checkout_bundle)) + .route("/api/billing/supporter", post(checkout_supporter)) + .route("/api/billing/webhook", post(webhook)) +} + +#[derive(Debug, Clone, Default)] +pub struct StripeConfig { + pub secret_key: String, + pub webhook_secret: String, +} + +impl StripeConfig { + pub fn is_configured(&self) -> bool { + !self.secret_key.is_empty() && !self.webhook_secret.is_empty() + } +} + +fn require_stripe(state: &AppState) -> Result<&StripeConfig, AppError> { + if !state.stripe.is_configured() { + return Err(AppError::NotConfigured( + "payments are not configured on this server".into(), + )); + } + Ok(&state.stripe) +} + +/// Creates a Checkout session and records it as pending. The item is not +/// granted here; the webhook does that once Stripe confirms payment. +async fn create_session( + state: &AppState, + user_id: &str, + kind: &str, + cosmetic_id: Option<&str>, + bundle_id: Option<&str>, + name: &str, + amount_cents: i32, +) -> Result { + let stripe = require_stripe(state)?; + let purchase_id = uuid::Uuid::new_v4().to_string(); + + let success = format!("{}/?purchase=done", state.frontend_origin); + let cancel = format!("{}/?purchase=cancelled", state.frontend_origin); + let amount = amount_cents.to_string(); + + // Stripe's API is form-encoded, not JSON. + let mut form: Vec<(String, String)> = vec![ + ("mode".into(), "payment".to_string()), + ("success_url".into(), success), + ("cancel_url".into(), cancel), + ("client_reference_id".into(), purchase_id.clone()), + ("line_items[0][quantity]".into(), "1".into()), + ("line_items[0][price_data][currency]".into(), "usd".into()), + ("line_items[0][price_data][unit_amount]".into(), amount), + ("line_items[0][price_data][product_data][name]".into(), name.to_string()), + // Echoed back on the webhook, so fulfilment does not have to trust + // anything the browser sends. + ("metadata[purchase_id]".into(), purchase_id.clone()), + ("metadata[user_id]".into(), user_id.to_string()), + ]; + if let Some(id) = cosmetic_id { + form.push(("metadata[cosmetic_id]".into(), id.to_string())); + } + + let res = state + .http + .post("https://api.stripe.com/v1/checkout/sessions") + .basic_auth(&stripe.secret_key, Some("")) + .form(&form) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))?; + + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + // Logged in full, returned as a generic failure: a processor error can + // carry account detail that does not belong in a browser. + tracing::error!("stripe checkout failed ({status}): {body}"); + return Err(AppError::Internal(anyhow::anyhow!("could not start checkout"))); + } + + #[derive(Deserialize)] + struct Session { + id: String, + url: String, + } + let session: Session = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + + sqlx::query( + "INSERT INTO purchases (id, user_id, kind, cosmetic_id, bundle_id, amount_cents, currency, status, session_id, created_at) + VALUES ($1, $2, $3, $4, $5, $6, 'usd', 'pending', $7, $8)", + ) + .bind(&purchase_id) + .bind(user_id) + .bind(kind) + .bind(cosmetic_id) + .bind(bundle_id) + .bind(amount_cents) + .bind(&session.id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + Ok(session.url) +} + +async fn checkout_cosmetic( + State(state): State>, + jar: CookieJar, + Path(cosmetic_id): Path, +) -> Result { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + // Price and name come from our own row, never from the request. + let row = sqlx::query("SELECT name, price_cents FROM cosmetics WHERE id = $1") + .bind(&cosmetic_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let name: String = row.try_get("name").unwrap_or_default(); + let price: i32 = row.try_get("price_cents").unwrap_or(0); + + // Already owned: charging again would be taking money for nothing. + let owned = sqlx::query("SELECT 1 FROM user_cosmetics WHERE user_id = $1 AND cosmetic_id = $2") + .bind(&user.id) + .bind(&cosmetic_id) + .fetch_optional(&state.db) + .await?; + if owned.is_some() { + return Err(AppError::InvalidInput("you already own that".into())); + } + + let url = create_session(&state, &user.id, "cosmetic", Some(&cosmetic_id), None, &name, price).await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +async fn checkout_bundle( + State(state): State>, + jar: CookieJar, + Path(bundle_id): Path, +) -> Result { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let row = sqlx::query("SELECT name, price_cents FROM bundles WHERE id = $1") + .bind(&bundle_id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let name: String = row.try_get("name").unwrap_or_default(); + let price: i32 = row.try_get("price_cents").unwrap_or(0); + + // A bundle whose every item is already owned has nothing to sell. + let remaining: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM bundle_items bi + WHERE bi.bundle_id = $1 + AND NOT EXISTS (SELECT 1 FROM user_cosmetics uc + WHERE uc.user_id = $2 AND uc.cosmetic_id = bi.cosmetic_id)", + ) + .bind(&bundle_id) + .bind(&user.id) + .fetch_one(&state.db) + .await?; + if remaining == 0 { + return Err(AppError::InvalidInput("you already own everything in that bundle".into())); + } + + let url = create_session(&state, &user.id, "bundle", None, Some(&bundle_id), &name, price).await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +async fn checkout_supporter( + State(state): State>, + jar: CookieJar, +) -> Result { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let url = create_session( + &state, + &user.id, + "supporter", + None, + None, + "TyperPunk supporter, 30 days", + SUPPORTER_PRICE_CENTS, + ) + .await?; + Ok(Json(serde_json::json!({ "url": url }))) +} + +/// Verifies Stripe's `Stripe-Signature` header against the raw request body. +/// +/// This is the whole security of the payment path. Without it, anyone who +/// knows the URL can post a message claiming a payment succeeded and be given +/// the goods. Compared in constant time, and the timestamp is checked so a +/// captured request cannot be replayed. +fn verify_signature(secret: &str, header: &str, body: &[u8]) -> bool { + let mut timestamp = None; + let mut signatures = Vec::new(); + for part in header.split(',') { + let Some((key, value)) = part.trim().split_once('=') else { continue }; + match key { + "t" => timestamp = value.parse::().ok(), + "v1" => signatures.push(value), + _ => {} + } + } + let Some(timestamp) = timestamp else { return false }; + if signatures.is_empty() { + return false; + } + + let now = OffsetDateTime::now_utc().unix_timestamp(); + if (now - timestamp).abs() > WEBHOOK_TOLERANCE_SECS { + return false; + } + + let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(timestamp.to_string().as_bytes()); + mac.update(b"."); + mac.update(body); + let expected = mac.finalize().into_bytes(); + let expected_hex = hex_encode(&expected); + + signatures.iter().any(|candidate| { + candidate.as_bytes().ct_eq(expected_hex.as_bytes()).into() + }) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + out +} + +#[derive(Deserialize)] +struct WebhookEvent { + #[serde(rename = "type")] + kind: String, + data: WebhookData, +} + +#[derive(Deserialize)] +struct WebhookData { + object: WebhookObject, +} + +#[derive(Deserialize)] +struct WebhookObject { + id: String, +} + +async fn webhook( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Result { + let stripe = require_stripe(&state)?; + let signature = headers + .get("stripe-signature") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + if !verify_signature(&stripe.webhook_secret, signature, &body) { + tracing::warn!("rejected a webhook with an invalid signature"); + return Err(AppError::Unauthorized); + } + + let event: WebhookEvent = + serde_json::from_slice(&body).map_err(|e| AppError::InvalidInput(e.to_string()))?; + + if event.kind != "checkout.session.completed" { + // Everything else is acknowledged and ignored, so Stripe stops + // retrying it. + return Ok(axum::http::StatusCode::OK); + } + + // Fulfilment keys off our own pending row, matched by session id. A + // webhook delivered twice updates a row that is already paid and grants + // nothing further. + let row = sqlx::query( + "SELECT id, user_id, kind, cosmetic_id, bundle_id, status FROM purchases WHERE session_id = $1", + ) + .bind(&event.data.object.id) + .fetch_optional(&state.db) + .await?; + + let Some(row) = row else { + tracing::warn!("webhook for an unknown session {}", event.data.object.id); + return Ok(axum::http::StatusCode::OK); + }; + let status: String = row.try_get("status").unwrap_or_default(); + if status == "paid" { + return Ok(axum::http::StatusCode::OK); + } + + let purchase_id: String = row.try_get("id").unwrap_or_default(); + let user_id: String = row.try_get("user_id").unwrap_or_default(); + let kind: String = row.try_get("kind").unwrap_or_default(); + let cosmetic_id: Option = row.try_get("cosmetic_id").unwrap_or(None); + let now = OffsetDateTime::now_utc(); + + let mut tx = state.db.begin().await?; + sqlx::query("UPDATE purchases SET status = 'paid', paid_at = $1 WHERE id = $2") + .bind(format_timestamp(now)) + .bind(&purchase_id) + .execute(&mut *tx) + .await?; + + match kind.as_str() { + "cosmetic" => { + if let Some(cid) = cosmetic_id { + sqlx::query( + "INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at) + VALUES ($1, $2, $3) ON CONFLICT (user_id, cosmetic_id) DO NOTHING", + ) + .bind(&user_id) + .bind(&cid) + .bind(format_timestamp(now)) + .execute(&mut *tx) + .await?; + } + } + "bundle" => { + let bundle_id: Option = row.try_get("bundle_id").unwrap_or(None); + if let Some(bid) = bundle_id { + // One statement rather than a loop: the set is defined by the + // bundle, so it cannot drift from what was paid for. + sqlx::query( + "INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at) + SELECT $1, bi.cosmetic_id, $2 FROM bundle_items bi WHERE bi.bundle_id = $3 + ON CONFLICT (user_id, cosmetic_id) DO NOTHING", + ) + .bind(&user_id) + .bind(format_timestamp(now)) + .bind(&bid) + .execute(&mut *tx) + .await?; + } + } + "supporter" => { + // Extends from whichever is later, so renewing early does not + // throw away the time already paid for. + let until = now + TimeDuration::days(SUPPORTER_DAYS); + sqlx::query( + "UPDATE users SET is_supporter = TRUE, + supporter_until = GREATEST(COALESCE(supporter_until, $1), $1) + WHERE id = $2", + ) + .bind(format_timestamp(until)) + .bind(&user_id) + .execute(&mut *tx) + .await?; + } + _ => {} + } + tx.commit().await?; + + tracing::info!("fulfilled {kind} purchase {purchase_id}"); + Ok(axum::http::StatusCode::OK) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sign(secret: &str, timestamp: i64, body: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(timestamp.to_string().as_bytes()); + mac.update(b"."); + mac.update(body); + format!("t={timestamp},v1={}", hex_encode(&mac.finalize().into_bytes())) + } + + #[test] + fn accepts_a_correct_signature() { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let body = br#"{"type":"checkout.session.completed"}"#; + assert!(verify_signature("whsec_test", &sign("whsec_test", now, body), body)); + } + + #[test] + fn rejects_a_forged_signature() { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let body = br#"{"type":"checkout.session.completed"}"#; + // Anyone can post this body; without the secret they cannot sign it. + assert!(!verify_signature("whsec_test", &format!("t={now},v1=deadbeef"), body)); + assert!(!verify_signature("whsec_test", &sign("wrong_secret", now, body), body)); + } + + #[test] + fn rejects_a_replayed_request() { + let old = OffsetDateTime::now_utc().unix_timestamp() - (WEBHOOK_TOLERANCE_SECS + 60); + let body = br#"{"type":"checkout.session.completed"}"#; + // Correctly signed, but captured and replayed later. + assert!(!verify_signature("whsec_test", &sign("whsec_test", old, body), body)); + } + + #[test] + fn rejects_a_tampered_body() { + let now = OffsetDateTime::now_utc().unix_timestamp(); + let signed = br#"{"amount":100}"#; + let tampered = br#"{"amount":999}"#; + assert!(!verify_signature("whsec_test", &sign("whsec_test", now, signed), tampered)); + } + + #[test] + fn rejects_a_missing_or_malformed_header() { + let body = br#"{}"#; + assert!(!verify_signature("whsec_test", "", body)); + assert!(!verify_signature("whsec_test", "nonsense", body)); + assert!(!verify_signature("whsec_test", "v1=abc", body)); + } +} diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs index 460fc66..b4532a8 100644 --- a/crates/server/src/cosmetics.rs +++ b/crates/server/src/cosmetics.rs @@ -1,4 +1,5 @@ use crate::auth::{current_user, format_timestamp}; +use time::OffsetDateTime; use crate::error::AppError; use crate::state::AppState; use axum::extract::{Path, State}; @@ -9,13 +10,12 @@ use axum_extra::extract::cookie::CookieJar; use serde::{Deserialize, Serialize}; use sqlx::Row; use std::sync::Arc; -use time::OffsetDateTime; pub fn router() -> Router> { Router::new() .route("/api/cosmetics", get(list_catalog)) .route("/api/cosmetics/me", get(my_cosmetics)) - .route("/api/cosmetics/:id/purchase", post(purchase)) + .route("/api/cosmetics/bundles", get(list_bundles)) .route("/api/cosmetics/:id/equip", post(equip)) .route("/api/cosmetics/unequip", post(unequip)) } @@ -52,6 +52,43 @@ struct MyCosmetics { equipped_caret: Option, equipped_flair: Option, equipped_sprite: Option, + is_supporter: bool, +} + +/// Bundles, with the items each contains so the store can show what is in one +/// and what the buyer already owns. +async fn list_bundles(State(state): State>) -> Result { + let rows = sqlx::query( + "SELECT b.id, b.name, b.description, b.price_cents, + COALESCE(SUM(c.price_cents), 0) AS full_price, + COALESCE(ARRAY_AGG(c.id ORDER BY c.id) FILTER (WHERE c.id IS NOT NULL), '{}') AS items + FROM bundles b + LEFT JOIN bundle_items bi ON bi.bundle_id = b.id + LEFT JOIN cosmetics c ON c.id = bi.cosmetic_id + GROUP BY b.id, b.name, b.description, b.price_cents, b.sort_order + ORDER BY b.sort_order", + ) + .fetch_all(&state.db) + .await?; + + let bundles: Vec = rows + .iter() + .map(|r| { + let items: Vec = r.try_get("items").unwrap_or_default(); + serde_json::json!({ + "id": r.try_get::("id").unwrap_or_default(), + "name": r.try_get::("name").unwrap_or_default(), + "description": r.try_get::, _>("description").unwrap_or(None), + "price_cents": r.try_get::("price_cents").unwrap_or(0), + // What the same items cost bought one at a time, so the store + // can show the saving rather than asserting one. + "full_price_cents": r.try_get::("full_price").unwrap_or(0), + "items": items, + }) + }) + .collect(); + + Ok(Json(bundles)) } async fn my_cosmetics(State(state): State>, jar: CookieJar) -> Result { @@ -63,10 +100,17 @@ async fn my_cosmetics(State(state): State>, jar: CookieJar) -> Res .await?; let owned: Vec = owned_rows.into_iter().filter_map(|r| r.try_get("cosmetic_id").ok()).collect(); - let equip_row = sqlx::query("SELECT equipped_caret, equipped_flair, equipped_sprite FROM users WHERE id = $1") - .bind(&user.id) - .fetch_one(&state.db) - .await?; + // is_supporter is computed from the expiry here for the same reason it is + // in /api/auth/me: the stored flag is set on payment and never cleared. + let equip_row = sqlx::query( + "SELECT equipped_caret, equipped_flair, equipped_sprite, + (is_supporter AND supporter_until IS NOT NULL AND supporter_until > $2) AS active_supporter + FROM users WHERE id = $1", + ) + .bind(&user.id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .fetch_one(&state.db) + .await?; Ok(Json(MyCosmetics { owned, @@ -79,37 +123,10 @@ async fn my_cosmetics(State(state): State>, jar: CookieJar) -> Res equipped_caret: equip_row.try_get::, _>("equipped_caret").unwrap_or(None), equipped_sprite: equip_row.try_get::, _>("equipped_sprite").unwrap_or(None), equipped_flair: equip_row.try_get::, _>("equipped_flair").unwrap_or(None), + is_supporter: equip_row.try_get::, _>("active_supporter").unwrap_or(None).unwrap_or(false), })) } -// Stub: grants ownership immediately with no actual charge. Wiring a real -// payment processor (Stripe or otherwise) needs the project owner's own -// merchant account - same situation as the Spotify integration needing its -// own developer credentials. This endpoint is the seam a real charge would -// slot into later without changing the ownership/equip logic around it. -async fn purchase(State(state): State>, jar: CookieJar, Path(cosmetic_id): Path) -> Result { - let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; - - let exists = sqlx::query("SELECT id FROM cosmetics WHERE id = $1") - .bind(&cosmetic_id) - .fetch_optional(&state.db) - .await?; - if exists.is_none() { - return Err(AppError::NotFound); - } - - // Postgres spells SQLite's INSERT OR IGNORE as an explicit conflict - // target; the pair is the table's primary key. - sqlx::query("INSERT INTO user_cosmetics (user_id, cosmetic_id, acquired_at) VALUES ($1, $2, $3) ON CONFLICT (user_id, cosmetic_id) DO NOTHING") - .bind(&user.id) - .bind(&cosmetic_id) - .bind(format_timestamp(OffsetDateTime::now_utc())) - .execute(&state.db) - .await?; - - Ok(axum::http::StatusCode::NO_CONTENT) -} - async fn equip(State(state): State>, jar: CookieJar, Path(cosmetic_id): Path) -> Result { let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index b291e64..beaabef 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,5 +1,6 @@ mod admin; mod anticheat; +mod billing; mod bot_results; mod auth; mod cosmetics; @@ -20,7 +21,6 @@ use axum::Router; use serde::Deserialize; use state::{AppState, SpotifyConfig}; use std::net::SocketAddr; -use std::str::FromStr; use std::sync::Arc; use tower_http::cors::CorsLayer; use tower::ServiceBuilder; @@ -95,6 +95,7 @@ fn build_app(app_state: Arc) -> Router { .merge(lyrics::router()) .merge(texts::router()) .merge(admin::router()) + .merge(billing::router()) .with_state(app_state) } @@ -194,8 +195,16 @@ async fn main() -> anyhow::Result<()> { tracing::warn!("SPOTIFY_CLIENT_ID/SECRET not set - the Lyrics mode's Spotify connection will return 501 until configured."); } + let stripe_config = billing::StripeConfig { + secret_key: std::env::var("STRIPE_SECRET_KEY").unwrap_or_default(), + webhook_secret: std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(), + }; + if !stripe_config.is_configured() { + tracing::warn!("STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET not set - the store will return 501 on checkout until configured."); + } + let race_texts = load_race_texts(); - let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, frontend_origin.clone())); + let app_state = Arc::new(AppState::new(db, cookie_secure, race_texts, spotify_config, stripe_config, frontend_origin.clone())); admin::bootstrap_admin(&app_state).await; bot_results::spawn(app_state.clone()); @@ -275,6 +284,7 @@ mod tests { attribution: None, }], SpotifyConfig::default(), + billing::StripeConfig::default(), "http://localhost:4173".to_string(), )); let app = build_app(app_state); diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index ff583ac..8696764 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -51,6 +51,7 @@ pub struct AppState { /// than per-room, since the pool itself never changes at runtime. pub race_texts: Vec, pub spotify: SpotifyConfig, + pub stripe: crate::billing::StripeConfig, pub frontend_origin: String, pub http: Client, } @@ -61,10 +62,12 @@ impl AppState { cookie_secure: bool, race_texts: Vec, spotify: SpotifyConfig, + stripe: crate::billing::StripeConfig, frontend_origin: String, ) -> Self { Self { db, + stripe, auth_rate_limiter: RateLimiter::new(10, Duration::from_secs(5 * 60)), // A genuine player finishes a test at most every several // seconds; 60 submissions in 5 minutes is generous headroom for -- cgit v1.2.3