diff options
Diffstat (limited to 'crates/server/src')
| -rw-r--r-- | crates/server/src/auth.rs | 18 | ||||
| -rw-r--r-- | crates/server/src/billing.rs | 461 | ||||
| -rw-r--r-- | crates/server/src/cosmetics.rs | 85 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 14 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 3 |
5 files changed, 540 insertions, 41 deletions
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<Arc<AppState>>, jar: CookieJar) -> Result<impl I let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; // Whether the ad slots are shown. Fetched here so the client has it with // the identity rather than asking a second time. - let is_supporter: bool = sqlx::query_scalar("SELECT is_supporter FROM users WHERE id = $1") - .bind(&user.id) - .fetch_optional(&state.db) - .await? - .unwrap_or(false); + // Derived from the expiry rather than read from the stored flag. A + // subscription that is only ever switched on never ends: nothing runs at + // midnight to switch it off, so the flag alone would make every supporter + // a supporter forever. Comparing against the expiry is self-correcting. + let is_supporter: bool = sqlx::query_scalar( + "SELECT is_supporter AND supporter_until IS NOT NULL AND supporter_until > $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<Arc<AppState>> { + 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<String, AppError> { + 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<Arc<AppState>>, + jar: CookieJar, + Path(cosmetic_id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + 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<Arc<AppState>>, + jar: CookieJar, + Path(bundle_id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + 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<Arc<AppState>>, + jar: CookieJar, +) -> Result<impl IntoResponse, AppError> { + 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::<i64>().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::<Sha256>::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<Arc<AppState>>, + headers: HeaderMap, + body: Bytes, +) -> Result<impl IntoResponse, AppError> { + 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<String> = 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<String> = 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::<Sha256>::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<Arc<AppState>> { 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<String>, equipped_flair: Option<String>, equipped_sprite: Option<String>, + 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<Arc<AppState>>) -> Result<impl IntoResponse, AppError> { + 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<serde_json::Value> = rows + .iter() + .map(|r| { + let items: Vec<String> = r.try_get("items").unwrap_or_default(); + serde_json::json!({ + "id": r.try_get::<String, _>("id").unwrap_or_default(), + "name": r.try_get::<String, _>("name").unwrap_or_default(), + "description": r.try_get::<Option<String>, _>("description").unwrap_or(None), + "price_cents": r.try_get::<i32, _>("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::<i64, _>("full_price").unwrap_or(0), + "items": items, + }) + }) + .collect(); + + Ok(Json(bundles)) } async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { @@ -63,10 +100,17 @@ async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Res .await?; let owned: Vec<String> = 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<Arc<AppState>>, jar: CookieJar) -> Res equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None), equipped_sprite: equip_row.try_get::<Option<String>, _>("equipped_sprite").unwrap_or(None), equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None), + is_supporter: equip_row.try_get::<Option<bool>, _>("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<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> { - 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<Arc<AppState>>, jar: CookieJar, Path(cosmetic_id): Path<String>) -> Result<impl IntoResponse, AppError> { 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<AppState>) -> 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<RaceText>, 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<RaceText>, 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 |