srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/server/src')
-rw-r--r--crates/server/src/auth.rs18
-rw-r--r--crates/server/src/billing.rs461
-rw-r--r--crates/server/src/cosmetics.rs85
-rw-r--r--crates/server/src/main.rs14
-rw-r--r--crates/server/src/state.rs3
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