diff options
| author | srdusr <[email protected]> | 2026-01-12 21:19:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-01-12 21:19:00 +0200 |
| commit | a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7 (patch) | |
| tree | 0f40a43b4276117f90adcf1ec394548b351b49fc /crates | |
| parent | b97beebfd527a12b182a12a556381768a728d9dc (diff) | |
| download | typerpunk-a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7.tar.gz typerpunk-a19c03bc6394ab08cc5a1a9fbabf5eb46b6791f7.zip | |
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.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/core/src/stats.rs | 3 | ||||
| -rw-r--r-- | crates/server/.env.example | 15 | ||||
| -rw-r--r-- | crates/server/Cargo.toml | 3 | ||||
| -rw-r--r-- | crates/server/build.rs | 7 | ||||
| -rw-r--r-- | crates/server/migrations/0014_billing.sql | 27 | ||||
| -rw-r--r-- | crates/server/migrations/0015_pricing_bundles.sql | 78 | ||||
| -rw-r--r-- | crates/server/migrations/0016_starter_price.sql | 4 | ||||
| -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 |
12 files changed, 675 insertions, 43 deletions
diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs index fe94468..e46e54b 100644 --- a/crates/core/src/stats.rs +++ b/crates/core/src/stats.rs @@ -117,13 +117,12 @@ impl Stats { let mut best_streak_local = 0; let mut correct_chars = 0usize; let mut incorrect_chars = 0usize; - let mut total_words = 0usize; let mut correct_words = 0usize; // Tokenize by whitespace to count words let input_words: Vec<&str> = input.split_whitespace().collect(); let target_words: Vec<&str> = target.split_whitespace().collect(); - total_words = input_words.len(); + let total_words = input_words.len(); for (iw, tw) in input_words.iter().zip(target_words.iter()) { if *iw == *tw { correct_words += 1; } } diff --git a/crates/server/.env.example b/crates/server/.env.example index b7501a1..91416c4 100644 --- a/crates/server/.env.example +++ b/crates/server/.env.example @@ -40,3 +40,18 @@ COOKIE_SECURE=0 # never asks end users for their own API keys, only its own. SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= + +# Required for the store. Register at https://dashboard.stripe.com and use a +# test-mode key (sk_test_...) for development. Checkout is hosted by Stripe, +# so no card details reach this server. +# +# STRIPE_WEBHOOK_SECRET comes from the webhook endpoint you create in the +# Stripe dashboard, pointing at /api/billing/webhook. Nothing is granted to a +# buyer until a webhook arrives with a valid signature, so without this the +# store takes payments and delivers nothing. For local work, `stripe listen +# --forward-to localhost:8787/api/billing/webhook` prints a secret to use. +# +# Without both, every checkout route answers 501 and the store stays visible +# but unbuyable. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index d0292fc..afb7f25 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -23,6 +23,9 @@ serde_json.workspace = true anyhow.workspace = true thiserror.workspace = true argon2.workspace = true +hmac.workspace = true +sha2.workspace = true +subtle.workspace = true uuid.workspace = true time.workspace = true dashmap.workspace = true diff --git a/crates/server/build.rs b/crates/server/build.rs new file mode 100644 index 0000000..35756ee --- /dev/null +++ b/crates/server/build.rs @@ -0,0 +1,7 @@ +// sqlx::migrate! reads the migrations directory at compile time, but cargo +// only watches source files. Without this, adding a migration does not make +// cargo rebuild, so the new binary silently ships the old migration set and +// the schema change never runs. +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/crates/server/migrations/0014_billing.sql b/crates/server/migrations/0014_billing.sql new file mode 100644 index 0000000..edc1bc6 --- /dev/null +++ b/crates/server/migrations/0014_billing.sql @@ -0,0 +1,27 @@ +- Payments. +-- +- Checkout is hosted by the processor, so no card details ever reach this +- server and it stays outside PCI scope. What is recorded here is only what +- is needed to fulfil an order and to answer "did this person pay". +CREATE TABLE purchases ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + - 'cosmetic' or 'supporter'. + kind TEXT NOT NULL CHECK (kind IN ('cosmetic', 'supporter')), + - The cosmetic bought, or NULL for a supporter subscription. + cosmetic_id TEXT REFERENCES cosmetics(id) ON DELETE SET NULL, + amount_cents INTEGER NOT NULL, + currency TEXT NOT NULL DEFAULT 'usd', + status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'failed', 'refunded')), + - The processor's own session id. Unique, so a webhook delivered twice + - cannot grant the same item twice: fulfilment keys off this row. + session_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + paid_at TEXT +); + +CREATE INDEX idx_purchases_user ON purchases(user_id); +CREATE INDEX idx_purchases_status ON purchases(status); + +- When the supporter subscription runs out. NULL means never subscribed. +ALTER TABLE users ADD COLUMN supporter_until TEXT; diff --git a/crates/server/migrations/0015_pricing_bundles.sql b/crates/server/migrations/0015_pricing_bundles.sql new file mode 100644 index 0000000..a38ce80 --- /dev/null +++ b/crates/server/migrations/0015_pricing_bundles.sql @@ -0,0 +1,78 @@ +- Prices per item, and bundles. +-- +- Everything was priced in three flat bands, so a plain colour swap cost the +- same as the most elaborate sprite and nothing signalled which items were +- worth more. Prices now vary by how much each item actually offers. +UPDATE cosmetics SET price_cents = CASE id + - Plain colours: the cheapest thing in the store. + WHEN 'caret-magenta' THEN 149 + WHEN 'caret-amber' THEN 149 + WHEN 'caret-cyan' THEN 149 + WHEN 'caret-crimson' THEN 149 + WHEN 'caret-lime' THEN 179 + WHEN 'caret-violet' THEN 179 + WHEN 'caret-ice' THEN 179 + WHEN 'caret-ember' THEN 199 + WHEN 'caret-bone' THEN 199 + - Deeper colours, held back as the ones worth saving for. + WHEN 'caret-void' THEN 349 + WHEN 'caret-signal' THEN 349 + - Flair, by how much drawing is in it. + WHEN 'flair-star' THEN 129 + WHEN 'flair-bolt' THEN 129 + WHEN 'flair-shard' THEN 129 + WHEN 'flair-eye' THEN 179 + WHEN 'flair-circuit' THEN 179 + WHEN 'flair-skull' THEN 199 + WHEN 'flair-crown' THEN 249 + WHEN 'flair-moth' THEN 249 + WHEN 'flair-reactor' THEN 299 + - Sprites are the most visible thing you own: everyone in the race sees + - one, so they carry the highest single-item prices. + WHEN 'sprite-dart' THEN 249 + WHEN 'sprite-signal' THEN 249 + WHEN 'sprite-blade' THEN 299 + WHEN 'sprite-helm' THEN 299 + WHEN 'sprite-core' THEN 349 + WHEN 'sprite-rocket' THEN 399 + ELSE price_cents +END; + +CREATE TABLE bundles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + price_cents INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE bundle_items ( + bundle_id TEXT NOT NULL REFERENCES bundles(id) ON DELETE CASCADE, + cosmetic_id TEXT NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE, + PRIMARY KEY (bundle_id, cosmetic_id) +); + +- A bundle is worth buying only if it is visibly cheaper than its parts, so +- each is priced below the sum of what it contains. +INSERT INTO bundles (id, name, description, price_cents, sort_order) VALUES + ('bundle-starter', 'Starter Kit', 'A caret, a flair and a sprite to make a profile your own.', 449, 1), + ('bundle-neon', 'Neon Set', 'The brightest carets in the store, together.', 549, 2), + ('bundle-racer', 'Racer Set', 'Every race sprite.', 1299, 3), + ('bundle-everything', 'The Lot', 'Every cosmetic currently in the store.', 3499, 4); + +INSERT INTO bundle_items (bundle_id, cosmetic_id) VALUES + ('bundle-starter', 'caret-cyan'), ('bundle-starter', 'flair-bolt'), ('bundle-starter', 'sprite-dart'), + ('bundle-neon', 'caret-magenta'), ('bundle-neon', 'caret-cyan'), ('bundle-neon', 'caret-lime'), ('bundle-neon', 'caret-signal'), + ('bundle-racer', 'sprite-dart'), ('bundle-racer', 'sprite-rocket'), ('bundle-racer', 'sprite-helm'), + ('bundle-racer', 'sprite-core'), ('bundle-racer', 'sprite-signal'), ('bundle-racer', 'sprite-blade'); + +- "The Lot" is defined as everything, rather than listed by hand, so it stays +- correct as items are added. +INSERT INTO bundle_items (bundle_id, cosmetic_id) + SELECT 'bundle-everything', id FROM cosmetics; + +- Purchases can now be of a bundle. +ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_kind_check; +ALTER TABLE purchases ADD CONSTRAINT purchases_kind_check + CHECK (kind IN ('cosmetic', 'supporter', 'bundle')); +ALTER TABLE purchases ADD COLUMN bundle_id TEXT REFERENCES bundles(id) ON DELETE SET NULL; diff --git a/crates/server/migrations/0016_starter_price.sql b/crates/server/migrations/0016_starter_price.sql new file mode 100644 index 0000000..f8bf973 --- /dev/null +++ b/crates/server/migrations/0016_starter_price.sql @@ -0,0 +1,4 @@ +- The Starter Kit saved $0.78 against $5.27 of parts. It is the cheapest way +- in and the one most people will see first, so the discount has to be worth +- reading. At 399 it saves $1.28, which is a quarter off. +UPDATE bundles SET price_cents = 399 WHERE id = 'bundle-starter'; 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 |