diff options
| author | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-09-11 21:53:00 +0200 |
| commit | a726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch) | |
| tree | 4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /crates/server/src | |
| parent | 8c95e90fa54db565919ec323818f77e7256812c8 (diff) | |
| download | typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip | |
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer
- Quick match: POST /api/multiplayer/quickmatch returns whichever room is
still filling, or opens one. Players never see a room code; joining by
code stays for racing specific people.
- Bots fill quick-match rooms after a short wait so a new game is never an
empty lobby. They only ever join quick-match rooms, never a room opened
by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so
two bots are never near each other's pace, and they stall to correct
mistakes rather than typing a clean straight line.
- Live player count via GET /api/multiplayer/online, shown on the
Multiplayer control and under the main menu's Multiplayer button.
- Per-racer colours: you are the theme accent, opponents take distinct hues
that stay the same from lobby to race.
- The countdown no longer holds the room lock for its full three seconds,
which is what reset clients mid-countdown.
Typing languages
- 16 languages for the generated-word modes, each with its own
high-frequency vocabulary rather than a translation of the English list.
- Picker in the top-right rail; non-English uses its own list at every
difficulty tier instead of falling back to English words.
Fix UTF-8 accuracy in the game core
- update_game_state mixed byte and character counts: total_characters_typed
accumulated byte-length deltas while total_correct_characters compared a
char index against that byte count. Equal on ASCII, so it went unnoticed;
a correctly typed Spanish passage scored 6%. The old byte slicing would
also have panicked if an index landed inside a multi-byte character.
Rewritten char-based, with regression tests.
Programming mode
- Replaced prose about programming with real code: 26 syntax-highlighted
snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line
by necessity, since the typing input is a single-line field.
Layout and readability
- One icon rail arrangement on every screen: Settings/Store under the
wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/
Multiplayer bottom-right.
- Main menu: mode picker moved out of the Single Player button, which it was
notching a divider through and pushing the label off-centre.
- Escape returns to the menu, closing any open popover first, and confirms
before abandoning a live race.
- Split --text-color and --sub-color per theme; they shared one value that
measured 3.65:1 against the background, below the 4.5:1 body-text floor.
- Semantic colours used in exactly one place each: gold for a personal best,
amber for the race countdown and the mobile-result badge.
- Passage now sits in the same place on the typing and end screens, and its
column is a whole number of characters wide so wrapping cannot leave a
permanent gap on the right.
- End screen: keystrokes and a correct/wrong/extra/missed split, attribution
carried over from the typing screen, and a graph with a separate error
axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'crates/server/src')
| -rw-r--r-- | crates/server/src/anticheat.rs | 81 | ||||
| -rw-r--r-- | crates/server/src/auth.rs | 285 | ||||
| -rw-r--r-- | crates/server/src/cosmetics.rs | 153 | ||||
| -rw-r--r-- | crates/server/src/error.rs | 52 | ||||
| -rw-r--r-- | crates/server/src/friends.rs | 193 | ||||
| -rw-r--r-- | crates/server/src/lyrics.rs | 67 | ||||
| -rw-r--r-- | crates/server/src/main.rs | 255 | ||||
| -rw-r--r-- | crates/server/src/multiplayer.rs | 594 | ||||
| -rw-r--r-- | crates/server/src/rate_limit.rs | 34 | ||||
| -rw-r--r-- | crates/server/src/spotify.rs | 237 | ||||
| -rw-r--r-- | crates/server/src/state.rs | 65 | ||||
| -rw-r--r-- | crates/server/src/stats.rs | 275 |
12 files changed, 2291 insertions, 0 deletions
diff --git a/crates/server/src/anticheat.rs b/crates/server/src/anticheat.rs new file mode 100644 index 0000000..44887f5 --- /dev/null +++ b/crates/server/src/anticheat.rs @@ -0,0 +1,81 @@ +// Heuristic, not proof - this flags a result for exclusion from the public +// leaderboard, it never rejects a submission outright. A false positive +// against a genuine elite typist costs them a leaderboard slot until +// reviewed; a false negative just means a bot's score sits on a personal +// profile nobody but that account holder sees ranked. Rejecting outright +// would make the first kind of mistake much more costly for no real gain +// against the second. + +/// Above the sustained world record (~216 WPM) but generous enough that a +/// genuine top-tier human run doesn't get flagged just for being very fast. +const WPM_CEILING: f64 = 220.0; + +/// Below this many samples, a coefficient-of-variation read is too noisy to +/// act on - a handful of keystrokes can look "uniform" by chance alone. +const MIN_KEYSTROKE_SAMPLES: usize = 20; + +/// Real human inter-keystroke timing varies quite a bit even at a steady +/// pace; a script replaying fixed or near-fixed delays reads as unusually +/// uniform by comparison. This threshold is deliberately loose - it's +/// meant to catch obviously mechanical timing, not to be a precise +/// biometric classifier. +const MIN_PLAUSIBLE_CV: f64 = 0.15; + +pub fn should_flag(wpm: f64, keystroke_intervals_ms: &Option<Vec<f64>>) -> bool { + if wpm > WPM_CEILING { + return true; + } + + if let Some(intervals) = keystroke_intervals_ms { + if intervals.len() >= MIN_KEYSTROKE_SAMPLES { + let mean = intervals.iter().sum::<f64>() / intervals.len() as f64; + if mean > 0.0 { + let variance = intervals.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / intervals.len() as f64; + let cv = variance.sqrt() / mean; + if cv < MIN_PLAUSIBLE_CV { + return true; + } + } + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_impossible_wpm() { + assert!(should_flag(300.0, &None)); + } + + #[test] + fn does_not_flag_ordinary_human_run() { + assert!(!should_flag(85.0, &None)); + } + + #[test] + fn flags_suspiciously_uniform_timing() { + let intervals: Vec<f64> = (0..30).map(|_| 120.0).collect(); + assert!(should_flag(90.0, &Some(intervals))); + } + + #[test] + fn does_not_flag_naturally_varied_timing() { + // Realistic-looking human variation around a 120ms average. + let intervals = vec![ + 95.0, 140.0, 110.0, 160.0, 100.0, 130.0, 90.0, 150.0, 120.0, 105.0, 135.0, 115.0, 145.0, 95.0, + 125.0, 110.0, 155.0, 100.0, 130.0, 120.0, 90.0, 140.0, 105.0, 160.0, 115.0, 135.0, 95.0, 150.0, + 120.0, 110.0, + ]; + assert!(!should_flag(90.0, &Some(intervals))); + } + + #[test] + fn ignores_too_few_samples() { + let intervals: Vec<f64> = (0..5).map(|_| 120.0).collect(); + assert!(!should_flag(90.0, &Some(intervals))); + } +} diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs new file mode 100644 index 0000000..d5294c4 --- /dev/null +++ b/crates/server/src/auth.rs @@ -0,0 +1,285 @@ +use crate::error::AppError; +use crate::state::AppState; +use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::{ConnectInfo, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use std::net::SocketAddr; +use std::sync::Arc; +use time::{Duration as TimeDuration, OffsetDateTime}; + +pub const SESSION_COOKIE: &str = "typerpunk_session"; +const SESSION_LIFETIME_DAYS: i64 = 30; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/auth/register", post(register)) + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/me", get(me)) + .route("/api/auth/token", post(issue_token)) +} + +#[derive(Debug, Serialize)] +pub struct UserView { + pub id: String, + pub username: String, +} + +#[derive(Debug, Deserialize)] +pub struct Credentials { + pub username: String, + pub password: String, +} + +// Deliberately permissive-but-bounded: rejects the empty/absurdly-long +// inputs that would either be meaningless or a resource-exhaustion vector +// against argon2 hashing, without being a picky validator of what a +// username "should" look like. +fn validate_username(username: &str) -> Result<(), AppError> { + if username.len() < 3 || username.len() > 24 { + return Err(AppError::InvalidInput("username must be 3-24 characters".into())); + } + if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(AppError::InvalidInput("username may only contain letters, numbers, and underscores".into())); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), AppError> { + if password.len() < 8 || password.len() > 256 { + return Err(AppError::InvalidInput("password must be 8-256 characters".into())); + } + Ok(()) +} + +fn hash_password(password: &str) -> Result<String, AppError> { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::Internal(anyhow::anyhow!("password hashing failed: {e}"))) +} + +fn verify_password(password: &str, hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { return false }; + Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() +} + +// `OffsetDateTime::to_string()` is NOT RFC3339 (it's e.g. "2024-01-01 +// 12:34:56.0 +00:00:00"), so timestamps are always written and read back via +// this explicit RFC3339 formatter - mismatching the two silently broke +// session lookups (parse failure treated as "no session found"). +pub(crate) fn format_timestamp(dt: OffsetDateTime) -> String { + dt.format(&time::format_description::well_known::Rfc3339).expect("valid RFC3339 timestamp") +} + +async fn create_session(db: &sqlx::SqlitePool, user_id: &str) -> Result<String, AppError> { + let session_id = uuid::Uuid::new_v4().to_string(); + let expires_at = OffsetDateTime::now_utc() + TimeDuration::days(SESSION_LIFETIME_DAYS); + sqlx::query("INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)") + .bind(&session_id) + .bind(user_id) + .bind(format_timestamp(expires_at)) + .execute(db) + .await?; + Ok(session_id) +} + +fn session_cookie(id: String, secure: bool) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE, id)) + .http_only(true) + .secure(secure) + .same_site(SameSite::Lax) + .path("/") + .max_age(TimeDuration::days(SESSION_LIFETIME_DAYS)) + .build() +} + +/// Resolves the signed-in user from the session cookie, if any and unexpired. +pub async fn current_user(db: &sqlx::SqlitePool, jar: &CookieJar) -> Option<UserView> { + let session_id = jar.get(SESSION_COOKIE)?.value().to_string(); + let row = sqlx::query( + "SELECT users.id as id, users.username as username, sessions.expires_at as expires_at + FROM sessions JOIN users ON users.id = sessions.user_id + WHERE sessions.id = ?", + ) + .bind(&session_id) + .fetch_optional(db) + .await + .ok()??; + + let expires_at: String = row.try_get("expires_at").ok()?; + let expires_at = OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339).ok()?; + if expires_at < OffsetDateTime::now_utc() { + return None; + } + + Some(UserView { id: row.try_get("id").ok()?, username: row.try_get("username").ok()? }) +} + +fn client_ip(addr: &SocketAddr) -> std::net::IpAddr { + addr.ip() +} + +async fn register( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + jar: CookieJar, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + validate_username(&creds.username)?; + validate_password(&creds.password)?; + + let existing = sqlx::query("SELECT id FROM users WHERE username = ?") + .bind(&creds.username) + .fetch_optional(&state.db) + .await?; + if existing.is_some() { + return Err(AppError::UsernameTaken); + } + + let user_id = uuid::Uuid::new_v4().to_string(); + let password_hash = hash_password(&creds.password)?; + sqlx::query("INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)") + .bind(&user_id) + .bind(&creds.username) + .bind(&password_hash) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + let session_id = create_session(&state.db, &user_id).await?; + let jar = jar.add(session_cookie(session_id, state.cookie_secure)); + Ok((jar, Json(UserView { id: user_id, username: creds.username }))) +} + +/// Verifies username/password against the users table. Shared by the +/// cookie-session login and the CLI-style token login below, so a +/// nonexistent username always takes the same dummy-hash timing path +/// regardless of which endpoint is asking. +async fn authenticate(db: &sqlx::SqlitePool, creds: &Credentials) -> Result<UserView, AppError> { + let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?") + .bind(&creds.username) + .fetch_optional(db) + .await?; + + let Some(row) = row else { + let _ = verify_password(&creds.password, "$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$dummydummydummydummydummydummy"); + return Err(AppError::InvalidCredentials); + }; + + let user_id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + let username: String = row.try_get("username").map_err(|e| AppError::Internal(e.into()))?; + let password_hash: String = row.try_get("password_hash").map_err(|e| AppError::Internal(e.into()))?; + + if !verify_password(&creds.password, &password_hash) { + return Err(AppError::InvalidCredentials); + } + + Ok(UserView { id: user_id, username }) +} + +async fn login( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + jar: CookieJar, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + let user = authenticate(&state.db, &creds).await?; + let session_id = create_session(&state.db, &user.id).await?; + let jar = jar.add(session_cookie(session_id, state.cookie_secure)); + Ok((jar, Json(user))) +} + +#[derive(Debug, Serialize)] +struct TokenResponse { + token: String, + user: UserView, +} + +// CLI-style clients (the TUI) have no cookie jar to hold a session, and +// replicating browser cookie semantics in a terminal app isn't worth it -- +// this hands back a long-lived bearer token instead, meant to be saved in a +// local config file and sent as `Authorization: Bearer <token>`. +async fn issue_token( + State(state): State<Arc<AppState>>, + ConnectInfo(addr): ConnectInfo<SocketAddr>, + Json(creds): Json<Credentials>, +) -> Result<impl IntoResponse, AppError> { + if !state.auth_rate_limiter.check(client_ip(&addr)) { + return Err(AppError::RateLimited); + } + let user = authenticate(&state.db, &creds).await?; + let token = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO api_tokens (token, user_id, created_at) VALUES (?, ?, ?)") + .bind(&token) + .bind(&user.id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + Ok(Json(TokenResponse { token, user })) +} + +/// Resolves a signed-in user from a bearer token (see api_tokens above), +/// for clients with no cookie jar. +pub async fn user_from_token(db: &sqlx::SqlitePool, token: &str) -> Option<UserView> { + let row = sqlx::query( + "SELECT users.id as id, users.username as username + FROM api_tokens JOIN users ON users.id = api_tokens.user_id + WHERE api_tokens.token = ?", + ) + .bind(token) + .fetch_optional(db) + .await + .ok()??; + Some(UserView { id: row.try_get("id").ok()?, username: row.try_get("username").ok()? }) +} + +/// Cookie session first (the browser's path), then an `Authorization: +/// Bearer <token>` header (the CLI's path) - lets a handler serve both +/// kinds of client without needing to know which one it's talking to. +pub async fn current_user_or_token( + db: &sqlx::SqlitePool, + jar: &CookieJar, + headers: &axum::http::HeaderMap, +) -> Option<UserView> { + if let Some(user) = current_user(db, jar).await { + return Some(user); + } + let token = headers + .get(axum::http::header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ")?; + user_from_token(db, token).await +} + +async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + if let Some(cookie) = jar.get(SESSION_COOKIE) { + sqlx::query("DELETE FROM sessions WHERE id = ?") + .bind(cookie.value()) + .execute(&state.db) + .await?; + } + let jar = jar.remove(Cookie::from(SESSION_COOKIE)); + Ok(jar) +} + +async fn me(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + match current_user(&state.db, &jar).await { + Some(user) => Ok(Json(user)), + None => Err(AppError::Unauthorized), + } +} diff --git a/crates/server/src/cosmetics.rs b/crates/server/src/cosmetics.rs new file mode 100644 index 0000000..b3eee28 --- /dev/null +++ b/crates/server/src/cosmetics.rs @@ -0,0 +1,153 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +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/:id/equip", post(equip)) + .route("/api/cosmetics/unequip", post(unequip)) +} + +#[derive(Debug, Serialize)] +struct Cosmetic { + id: String, + name: String, + category: String, + price_cents: i64, + value: String, +} + +async fn list_catalog(State(state): State<Arc<AppState>>) -> Result<impl IntoResponse, AppError> { + let rows = sqlx::query("SELECT id, name, category, price_cents, value FROM cosmetics ORDER BY category, price_cents") + .fetch_all(&state.db) + .await?; + let items: Vec<Cosmetic> = rows + .into_iter() + .map(|row| Cosmetic { + id: row.try_get("id").unwrap_or_default(), + name: row.try_get("name").unwrap_or_default(), + category: row.try_get("category").unwrap_or_default(), + price_cents: row.try_get("price_cents").unwrap_or_default(), + value: row.try_get("value").unwrap_or_default(), + }) + .collect(); + Ok(Json(items)) +} + +#[derive(Debug, Serialize)] +struct MyCosmetics { + owned: Vec<String>, + equipped_caret: Option<String>, + equipped_flair: Option<String>, +} + +async fn my_cosmetics(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + let owned_rows = sqlx::query("SELECT cosmetic_id FROM user_cosmetics WHERE user_id = ?") + .bind(&user.id) + .fetch_all(&state.db) + .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 FROM users WHERE id = ?") + .bind(&user.id) + .fetch_one(&state.db) + .await?; + + Ok(Json(MyCosmetics { + owned, + // Explicit Option<String> turbofish - try_get(...).ok() here was + // ambiguous enough that type inference picked plain String, and + // sqlx's SQLite decode of a NULL column into String silently + // produced an empty string instead of erroring the way decoding + // into Option<String> correctly does. Found via a real NULL column + // round-tripping as "" instead of JSON null. + equipped_caret: equip_row.try_get::<Option<String>, _>("equipped_caret").unwrap_or(None), + equipped_flair: equip_row.try_get::<Option<String>, _>("equipped_flair").unwrap_or(None), + })) +} + +// 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 = ?") + .bind(&cosmetic_id) + .fetch_optional(&state.db) + .await?; + if exists.is_none() { + return Err(AppError::NotFound); + } + + sqlx::query("INSERT OR IGNORE INTO user_cosmetics (user_id, cosmetic_id, acquired_at) VALUES (?, ?, ?)") + .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)?; + + let row = sqlx::query( + "SELECT cosmetics.category as category FROM cosmetics + JOIN user_cosmetics ON user_cosmetics.cosmetic_id = cosmetics.id + WHERE cosmetics.id = ? AND user_cosmetics.user_id = ?", + ) + .bind(&cosmetic_id) + .bind(&user.id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::InvalidInput("you don't own this cosmetic".into()))?; + + let category: String = row.try_get("category").map_err(|e| AppError::Internal(e.into()))?; + let column = match category.as_str() { + "caret" => "equipped_caret", + "flair" => "equipped_flair", + _ => return Err(AppError::Internal(anyhow::anyhow!("unknown cosmetic category"))), + }; + + // Column name comes from a hardcoded match above, never from request + // input, so this is safe despite not being a bind parameter. + let query = format!("UPDATE users SET {column} = ? WHERE id = ?"); + sqlx::query(&query).bind(&cosmetic_id).bind(&user.id).execute(&state.db).await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct UnequipRequest { + category: String, +} + +async fn unequip(State(state): State<Arc<AppState>>, jar: CookieJar, Json(body): Json<UnequipRequest>) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let column = match body.category.as_str() { + "caret" => "equipped_caret", + "flair" => "equipped_flair", + _ => return Err(AppError::InvalidInput("category must be 'caret' or 'flair'".into())), + }; + let query = format!("UPDATE users SET {column} = NULL WHERE id = ?"); + sqlx::query(&query).bind(&user.id).execute(&state.db).await?; + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..639dc09 --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,52 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +// A single error type for every handler, so failure modes map to HTTP status +// consistently instead of each handler picking its own. Messages returned to +// the client are deliberately generic for anything auth-related - see +// AppError::InvalidCredentials - so a failed login never reveals whether +// the username exists. +#[derive(Debug)] +pub enum AppError { + InvalidCredentials, + UsernameTaken, + InvalidInput(String), + Unauthorized, + NotFound, + NotConfigured(String), + RateLimited, + Internal(anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::InvalidCredentials => (StatusCode::UNAUTHORIZED, "invalid username or password".to_string()), + AppError::UsernameTaken => (StatusCode::CONFLICT, "username already taken".to_string()), + AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "not signed in".to_string()), + AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), + AppError::NotConfigured(msg) => (StatusCode::NOT_IMPLEMENTED, msg.clone()), + AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "too many attempts, try again later".to_string()), + AppError::Internal(err) => { + tracing::error!("internal error: {err:#}"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()) + } + }; + (status, Json(json!({ "error": message }))).into_response() + } +} + +impl From<anyhow::Error> for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::Internal(err) + } +} + +impl From<sqlx::Error> for AppError { + fn from(err: sqlx::Error) -> Self { + AppError::Internal(err.into()) + } +} diff --git a/crates/server/src/friends.rs b/crates/server/src/friends.rs new file mode 100644 index 0000000..e720dbf --- /dev/null +++ b/crates/server/src/friends.rs @@ -0,0 +1,193 @@ +use crate::auth::{current_user_or_token, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::http::HeaderMap; +use axum::response::IntoResponse; +use axum::routing::{delete, get, post}; +use axum::{Json, Router}; +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/friends", get(list_friends)) + .route("/api/friends/request", post(send_request)) + .route("/api/friends/:id/accept", post(accept_request)) + .route("/api/friends/:id", delete(remove_friendship)) +} + +#[derive(Debug, Serialize)] +struct FriendEntry { + friendship_id: String, + user_id: String, + username: String, +} + +#[derive(Debug, Serialize)] +struct FriendsList { + friends: Vec<FriendEntry>, + incoming_requests: Vec<FriendEntry>, + outgoing_requests: Vec<FriendEntry>, +} + +fn row_to_entry(row: &sqlx::sqlite::SqliteRow, friendship_id_col: &str, user_id_col: &str, username_col: &str) -> FriendEntry { + FriendEntry { + friendship_id: row.try_get(friendship_id_col).unwrap_or_default(), + user_id: row.try_get(user_id_col).unwrap_or_default(), + username: row.try_get(username_col).unwrap_or_default(), + } +} + +async fn list_friends(State(state): State<Arc<AppState>>, jar: CookieJar, headers: HeaderMap) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + // Accepted, in either direction - the "other" user is whichever side + // isn't us, so this always returns the friend's identity regardless of + // who originally sent the request. + let accepted = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships + JOIN users ON users.id = CASE WHEN friendships.requester_id = ? THEN friendships.addressee_id ELSE friendships.requester_id END + WHERE friendships.status = 'accepted' AND (friendships.requester_id = ? OR friendships.addressee_id = ?)", + ) + .bind(&user.id).bind(&user.id).bind(&user.id) + .fetch_all(&state.db) + .await?; + + let incoming = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships JOIN users ON users.id = friendships.requester_id + WHERE friendships.status = 'pending' AND friendships.addressee_id = ?", + ) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + + let outgoing = sqlx::query( + "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username + FROM friendships JOIN users ON users.id = friendships.addressee_id + WHERE friendships.status = 'pending' AND friendships.requester_id = ?", + ) + .bind(&user.id) + .fetch_all(&state.db) + .await?; + + Ok(Json(FriendsList { + friends: accepted.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + incoming_requests: incoming.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + outgoing_requests: outgoing.iter().map(|r| row_to_entry(r, "friendship_id", "user_id", "username")).collect(), + })) +} + +#[derive(Debug, Deserialize)] +struct FriendRequestBody { + username: String, +} + +async fn send_request( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Json(body): Json<FriendRequestBody>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let target = sqlx::query("SELECT id FROM users WHERE username = ?") + .bind(&body.username) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let target_id: String = target.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + + if target_id == user.id { + return Err(AppError::InvalidInput("can't friend yourself".into())); + } + + let existing = sqlx::query( + "SELECT id, requester_id, status FROM friendships + WHERE (requester_id = ? AND addressee_id = ?) OR (requester_id = ? AND addressee_id = ?)", + ) + .bind(&user.id).bind(&target_id) + .bind(&target_id).bind(&user.id) + .fetch_optional(&state.db) + .await?; + + if let Some(row) = existing { + let status: String = row.try_get("status").unwrap_or_default(); + let requester_id: String = row.try_get("requester_id").unwrap_or_default(); + if status == "accepted" { + return Err(AppError::InvalidInput("already friends".into())); + } + // The other person already sent a request - accept it instead of + // creating a second, redundant pending row in the opposite direction. + if requester_id == target_id { + let id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + sqlx::query("UPDATE friendships SET status = 'accepted' WHERE id = ?") + .bind(&id) + .execute(&state.db) + .await?; + return Ok(axum::http::StatusCode::NO_CONTENT); + } + return Err(AppError::InvalidInput("request already pending".into())); + } + + sqlx::query("INSERT INTO friendships (id, requester_id, addressee_id, status, created_at) VALUES (?, ?, ?, 'pending', ?)") + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&target_id) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +async fn accept_request( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let result = sqlx::query( + "UPDATE friendships SET status = 'accepted' WHERE id = ? AND addressee_id = ? AND status = 'pending'", + ) + .bind(&id) + .bind(&user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} + +// Covers declining an incoming request, cancelling one you sent, and +// unfriending an accepted one - all three are just "delete the row", scoped +// to rows the caller is actually a party to. +async fn remove_friendship( + State(state): State<Arc<AppState>>, + jar: CookieJar, + headers: HeaderMap, + Path(id): Path<String>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user_or_token(&state.db, &jar, &headers).await.ok_or(AppError::Unauthorized)?; + + let result = sqlx::query("DELETE FROM friendships WHERE id = ? AND (requester_id = ? OR addressee_id = ?)") + .bind(&id) + .bind(&user.id) + .bind(&user.id) + .execute(&state.db) + .await?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound); + } + Ok(axum::http::StatusCode::NO_CONTENT) +} diff --git a/crates/server/src/lyrics.rs b/crates/server/src/lyrics.rs new file mode 100644 index 0000000..d7c8710 --- /dev/null +++ b/crates/server/src/lyrics.rs @@ -0,0 +1,67 @@ +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +pub fn router() -> Router<Arc<AppState>> { + Router::new().route("/api/lyrics", get(get_lyrics)) +} + +#[derive(Debug, Deserialize)] +pub struct LyricsQuery { + pub artist: String, + pub track: String, + pub duration: Option<u64>, +} + +#[derive(Debug, Deserialize)] +struct LrclibResponse { + #[serde(rename = "syncedLyrics")] + synced_lyrics: Option<String>, + #[serde(rename = "plainLyrics")] + plain_lyrics: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct LyricsResult { + /// Synced (.lrc-format) lyrics, when lrclib has them - feed straight + /// into the same parseCustomContent('lyrics.lrc') path Custom Text + /// already uses for uploaded .lrc files. + pub lrc: Option<String>, + /// Untimed fallback when only plain lyrics exist - still typeable, just + /// without the sync-to-playback behavior the Lyrics mode is built for. + pub plain: Option<String>, +} + +async fn get_lyrics(State(state): State<Arc<AppState>>, Query(q): Query<LyricsQuery>) -> Result<impl IntoResponse, AppError> { + if q.artist.trim().is_empty() || q.track.trim().is_empty() { + return Err(AppError::InvalidInput("artist and track are required".into())); + } + + let mut req = state + .http + .get("https://lrclib.net/api/get") + .query(&[("artist_name", q.artist.as_str()), ("track_name", q.track.as_str())]); + if let Some(duration) = q.duration { + req = req.query(&[("duration", duration)]); + } + + let res = req.send().await.map_err(|e| AppError::Internal(e.into()))?; + if res.status() == reqwest::StatusCode::NOT_FOUND { + return Err(AppError::NotFound); + } + if !res.status().is_success() { + return Err(AppError::Internal(anyhow::anyhow!("lrclib returned {}", res.status()))); + } + + let body: LrclibResponse = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + if body.synced_lyrics.is_none() && body.plain_lyrics.is_none() { + return Err(AppError::NotFound); + } + + Ok(Json(LyricsResult { lrc: body.synced_lyrics, plain: body.plain_lyrics })) +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..7e9e9a8 --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,255 @@ +mod anticheat; +mod auth; +mod cosmetics; +mod error; +mod friends; +mod lyrics; +mod multiplayer; +mod rate_limit; +mod spotify; +mod state; +mod stats; + +use axum::http::{HeaderValue, Method}; +use axum::routing::get; +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_http::trace::TraceLayer; + +#[derive(Deserialize)] +struct TextEntry { + content: String, +} + +// Every multiplayer room draws from this same pool so every player in a +// room races the identical passage. Reuses the dataset already shared by +// the CLI and web app instead of maintaining a separate word list here -- +// falls back to a couple of plain sentences if the file isn't reachable +// (e.g. the server binary run from somewhere other than the repo root), +// so a room can still start rather than erroring on an empty pool. +fn load_race_texts() -> Vec<String> { + let path = std::env::var("TEXTS_JSON_PATH").unwrap_or_else(|_| "texts.json".to_string()); + match std::fs::read_to_string(&path).ok().and_then(|raw| serde_json::from_str::<Vec<TextEntry>>(&raw).ok()) { + Some(entries) if !entries.is_empty() => entries.into_iter().map(|e| e.content).collect(), + _ => { + tracing::warn!("could not load race texts from {path} - using a small built-in fallback pool"); + vec![ + "The quick brown fox jumps over the lazy dog.".to_string(), + "Pack my box with five dozen liquor jugs.".to_string(), + ] + } + } +} + +// Pulled out of main() so tests can assemble the exact same router against +// an in-memory test database, instead of a parallel hand-maintained copy of +// this list drifting out of sync with the real one. +fn build_app(app_state: Arc<AppState>) -> Router { + Router::new() + .route("/api/health", get(|| async { "ok" })) + .merge(auth::router()) + .merge(stats::router()) + .merge(friends::router()) + .merge(cosmetics::router()) + .merge(multiplayer::router()) + .merge(spotify::router()) + .merge(lyrics::router()) + .with_state(app_state) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Ignored if absent - production deployments are expected to set real + // env vars directly rather than ship a .env file. + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let database_url = std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite://typerpunk.db".to_string()); + let cookie_secure = std::env::var("COOKIE_SECURE").map(|v| v == "1" || v == "true").unwrap_or(false); + let frontend_origin = std::env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| "http://localhost:4173".to_string()); + let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8787); + + let connect_options = sqlx::sqlite::SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); + let db = sqlx::sqlite::SqlitePoolOptions::new().max_connections(10).connect_with(connect_options).await?; + sqlx::migrate!("./migrations").run(&db).await?; + + if !cookie_secure { + tracing::warn!("COOKIE_SECURE is off - session cookies will be sent over plain HTTP. Set COOKIE_SECURE=1 behind TLS in production."); + } + + let cors = CorsLayer::new() + .allow_origin(frontend_origin.parse::<HeaderValue>()?) + .allow_credentials(true) + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([axum::http::header::CONTENT_TYPE]); + + let spotify_config = SpotifyConfig { + client_id: std::env::var("SPOTIFY_CLIENT_ID").unwrap_or_default(), + client_secret: std::env::var("SPOTIFY_CLIENT_SECRET").unwrap_or_default(), + redirect_uri: std::env::var("SPOTIFY_REDIRECT_URI") + .unwrap_or_else(|_| format!("http://localhost:{port}/api/spotify/callback")), + }; + if !spotify_config.is_configured() { + tracing::warn!("SPOTIFY_CLIENT_ID/SECRET not set - the Lyrics mode's Spotify connection will return 501 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 = build_app(app_state).layer(cors).layer(TraceLayer::new_for_http()); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("typerpunk-server listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?; + + Ok(()) +} + +// Real HTTP integration tests: each spins up the actual router (build_app, +// the same function main() uses) against an in-memory database on a random +// local port, and drives it with a real reqwest client - exercising the +// full stack (routing, extractors, cookies, JSON) rather than calling +// handler functions directly, which only proves the inner logic works and +// not that the wiring around it does too. +#[cfg(test)] +mod tests { + use super::*; + use state::SpotifyConfig; + + async fn spawn_test_server() -> String { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("failed to open in-memory test database"); + sqlx::migrate!("./migrations").run(&db).await.expect("failed to run migrations"); + + let app_state = Arc::new(AppState::new( + db, + false, + vec!["The quick brown fox jumps over the lazy dog.".to_string()], + SpotifyConfig::default(), + "http://localhost:4173".to_string(), + )); + let app = build_app(app_state); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("failed to bind test listener"); + let addr = listener.local_addr().expect("test listener has no local addr"); + tokio::spawn(async move { + axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await.ok(); + }); + format!("http://{addr}") + } + + fn test_client() -> reqwest::Client { + reqwest::Client::builder().cookie_store(true).build().expect("failed to build test http client") + } + + async fn register(client: &reqwest::Client, base: &str, username: &str) { + let resp = client + .post(format!("{base}/api/auth/register")) + .json(&serde_json::json!({ "username": username, "password": "correcthorsebattery1" })) + .send() + .await + .expect("register request failed"); + assert!(resp.status().is_success(), "register failed for {username}: {}", resp.status()); + } + + #[tokio::test] + async fn mutual_friend_request_accept() { + let base = spawn_test_server().await; + let alice = test_client(); + let bob = test_client(); + register(&alice, &base, "alice_friend_test").await; + register(&bob, &base, "bob_friend_test").await; + + let resp = alice + .post(format!("{base}/api/friends/request")) + .json(&serde_json::json!({ "username": "bob_friend_test" })) + .send() + .await + .expect("friend request failed"); + assert!(resp.status().is_success(), "friend request should succeed: {}", resp.status()); + + // Bob should see Alice as an incoming request before accepting. + let bob_list: serde_json::Value = + bob.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let incoming = bob_list["incoming_requests"].as_array().expect("incoming_requests missing"); + assert_eq!(incoming.len(), 1, "bob should have exactly one incoming request"); + let friendship_id = incoming[0]["friendship_id"].as_str().expect("friendship_id missing").to_string(); + assert_eq!(incoming[0]["username"], "alice_friend_test"); + + let resp = bob.post(format!("{base}/api/friends/{friendship_id}/accept")).send().await.unwrap(); + assert!(resp.status().is_success(), "accept should succeed: {}", resp.status()); + + // After accepting, both sides should list each other as an + // accepted friend, and neither should have any pending requests + // left over. + let alice_list: serde_json::Value = + alice.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let alice_friends = alice_list["friends"].as_array().unwrap(); + assert_eq!(alice_friends.len(), 1, "alice should have exactly one friend"); + assert_eq!(alice_friends[0]["username"], "bob_friend_test"); + assert!(alice_list["outgoing_requests"].as_array().unwrap().is_empty()); + + let bob_list: serde_json::Value = + bob.get(format!("{base}/api/friends")).send().await.unwrap().json().await.unwrap(); + let bob_friends = bob_list["friends"].as_array().unwrap(); + assert_eq!(bob_friends.len(), 1, "bob should have exactly one friend"); + assert_eq!(bob_friends[0]["username"], "alice_friend_test"); + assert!(bob_list["incoming_requests"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn leaderboard_ranks_by_wpm_descending() { + let base = spawn_test_server().await; + let racers = [("leaderboard_low", 40.0), ("leaderboard_high", 95.0), ("leaderboard_mid", 70.0)]; + + for (name, wpm) in racers { + let client = test_client(); + register(&client, &base, name).await; + let resp = client + .post(format!("{base}/api/stats")) + .json(&serde_json::json!({ + "mode_key": "time-30", + "wpm": wpm, + "raw_wpm": wpm, + "accuracy": 98.0, + "time_seconds": 30.0, + "device_type": "desktop", + })) + .send() + .await + .expect("stats submission failed"); + assert!(resp.status().is_success(), "stats submission should succeed for {name}: {}", resp.status()); + } + + let anon = test_client(); + let entries: serde_json::Value = anon + .get(format!("{base}/api/leaderboard?mode=time-30")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let entries = entries.as_array().expect("leaderboard should return an array"); + assert_eq!(entries.len(), 3, "expected all three submitted results to appear"); + + let names: Vec<&str> = entries.iter().map(|e| e["username"].as_str().unwrap()).collect(); + assert_eq!( + names, + vec!["leaderboard_high", "leaderboard_mid", "leaderboard_low"], + "leaderboard should be ordered by wpm descending" + ); + } +} diff --git a/crates/server/src/multiplayer.rs b/crates/server/src/multiplayer.rs new file mode 100644 index 0000000..54df6b2 --- /dev/null +++ b/crates/server/src/multiplayer.rs @@ -0,0 +1,594 @@ +use crate::state::AppState; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures_util::{SinkExt, StreamExt}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, Mutex}; +use typerpunk_core::multiplayer::{ClientMessage, PlayerInfo, ServerMessage}; + +// A stalled connection (tab closed without a clean disconnect, laptop went +// to sleep mid-race) otherwise holds its room slot and the room's resources +// forever - there is no other cleanup path. This is a hard requirement +// carried over from the original design note on this module, not a nicety: +// every connection is force-closed after this long with no activity. +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); +const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(5); +// How many players quick match will pack into one room before opening a new +// one. Joining by code is deliberately not capped by this - a group racing +// each other on a shared code decides its own size; this only bounds the +// rooms quick match hands out to strangers. +const QUICK_MATCH_CAPACITY: usize = 5; +// A brand-new game has nobody in it, and an empty lobby is where a player +// leaves and does not come back. Quick-match rooms fill with bots after a +// short wait so there is always a race to join. They are ordinary room +// members: they appear in the player list, they are typed against, and they +// can be beaten. +const BOT_JOIN_DELAY: Duration = Duration::from_secs(6); +const BOT_TICK: Duration = Duration::from_millis(250); +// Two clearly separated tiers rather than one wide range: a room of bots that +// all land within a few WPM of each other reads as one opponent duplicated, +// and gives a human nothing to place against. A bot takes the tier the room +// does not already have, so two bots are never near each other's pace. +const BOT_SLOW_WPM: (f32, f32) = (34.0, 48.0); +const BOT_FAST_WPM: (f32, f32) = (72.0, 88.0); +// Nobody types perfectly. A bot's accuracy sets both what it reports at the +// finish and how often it stalls mid-race to "correct" itself. +const BOT_MIN_ACCURACY: f32 = 88.0; +const BOT_MAX_ACCURACY: f32 = 99.0; +// How long a correction costs, in ticks. +const BOT_CORRECTION_TICKS: u32 = 2; +const MAX_BOTS_PER_ROOM: usize = 2; +const BOT_NAMES: &[&str] = &[ + "Ghostwire", "NullPointer", "Kanji", "Sable", "Vex", "Orbit", "Static", + "Halcyon", "Nyx", "Drift", "Ember", "Kilo", "Rune", "Zephyr", "Onyx", + "Pixel", "Quartz", "Sigil", "Tessa", "Umbra", +]; + +const ROOM_CODE_CHARS: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I - easy to misread aloud + +#[derive(Debug, Clone, PartialEq)] +enum RoomStatus { + Lobby, + Countdown, + Racing, + Finished, +} + +pub(crate) struct Player { + name: String, + ready: bool, + sender: mpsc::UnboundedSender<Message>, + /// Bots have no socket behind `sender`; the receiver is dropped the moment + /// they are created, so broadcasts to them fail silently and harmlessly. + is_bot: bool, + /// Only meaningful for bots: the pace they type at. + target_wpm: f32, + /// Only meaningful for bots: the accuracy they finish with. + target_accuracy: f32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) enum DeviceFilter { + #[default] + Everyone, + DesktopOnly, +} + +pub(crate) struct Room { + players: HashMap<String, Player>, + status: RoomStatus, + text: Option<String>, + finish_order: Vec<String>, + device_filter: DeviceFilter, + /// Whether the room may be filled with bots while it waits. True only for + /// quick match: a room you opened to race specific friends must stay + /// exactly as empty as you left it until they arrive. + allow_bots: bool, +} + +impl Room { + fn new(device_filter: DeviceFilter) -> Self { + Self { players: HashMap::new(), status: RoomStatus::Lobby, text: None, finish_order: Vec::new(), device_filter, allow_bots: false } + } + + fn new_quick_match(device_filter: DeviceFilter) -> Self { + Self { allow_bots: true, ..Self::new(device_filter) } + } + + fn human_count(&self) -> usize { + self.players.values().filter(|p| !p.is_bot).count() + } +} + +pub type RoomRegistry = Arc<dashmap::DashMap<String, Arc<Mutex<Room>>>>; + +pub fn new_registry() -> RoomRegistry { + Arc::new(dashmap::DashMap::new()) +} + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/multiplayer/rooms", post(create_room)) + .route("/api/multiplayer/quickmatch", post(quick_match)) + .route("/api/multiplayer/online", get(online_count)) + .route("/ws/multiplayer/:room_code", get(ws_handler)) +} + +#[derive(Debug, Serialize)] +struct CreateRoomResponse { + room_code: String, +} + +#[derive(Debug, Deserialize, Default)] +struct CreateRoomRequest { + /// "desktop_only" to reject mobile joiners at the door; anything else + /// (including absent) means no restriction. + #[serde(default)] + device_filter: Option<String>, +} + +fn generate_room_code() -> String { + let mut rng = rand::thread_rng(); + (0..5).map(|_| ROOM_CODE_CHARS[rng.gen_range(0..ROOM_CODE_CHARS.len())] as char).collect() +} + +// Body is optional - a plain POST with no JSON at all (or an empty object) +// is a normal "everyone" room, not a client error. +async fn create_room(State(state): State<Arc<AppState>>, body: Option<Json<CreateRoomRequest>>) -> impl IntoResponse { + let device_filter = match body.and_then(|b| b.0.device_filter) { + Some(f) if f == "desktop_only" => DeviceFilter::DesktopOnly, + _ => DeviceFilter::Everyone, + }; + + let mut code = generate_room_code(); + while state.rooms.contains_key(&code) { + code = generate_room_code(); + } + state.rooms.insert(code.clone(), Arc::new(Mutex::new(Room::new(device_filter)))); + Json(CreateRoomResponse { room_code: code }) +} + +// Auto-matchmaking: drop the caller into whichever room is still open, and +// only create one when none is. This is the path the "Find a Race" button +// uses, so a player never has to see or exchange a room code - codes remain +// for the deliberate "race my friends" case. +// +// try_lock, never lock: a room mid-countdown holds its own lock for the full +// three seconds on purpose (see the Ready handler), and quick match must not +// stall behind that - a room it cannot inspect right now is simply not a +// candidate. Room handles are cloned out of the registry before any locking +// so no DashMap shard guard is ever held while locking a room. +async fn quick_match(State(state): State<Arc<AppState>>, body: Option<Json<CreateRoomRequest>>) -> impl IntoResponse { + let device_filter = match body.and_then(|b| b.0.device_filter) { + Some(f) if f == "desktop_only" => DeviceFilter::DesktopOnly, + _ => DeviceFilter::Everyone, + }; + + let candidates: Vec<(String, Arc<Mutex<Room>>)> = + state.rooms.iter().map(|e| (e.key().clone(), e.value().clone())).collect(); + + for (code, room) in candidates { + let Ok(room) = room.try_lock() else { continue }; + if room.status == RoomStatus::Lobby + && room.device_filter == device_filter + && room.players.len() < QUICK_MATCH_CAPACITY + { + return Json(CreateRoomResponse { room_code: code }); + } + } + + let mut code = generate_room_code(); + while state.rooms.contains_key(&code) { + code = generate_room_code(); + } + state.rooms.insert(code.clone(), Arc::new(Mutex::new(Room::new_quick_match(device_filter)))); + Json(CreateRoomResponse { room_code: code }) +} + +#[derive(Serialize)] +struct OnlineResponse { + players: usize, + rooms: usize, +} + +// How many people are in multiplayer rooms right now, for the header's live +// counter. Public and unauthenticated - it is a "is anyone around to race" +// signal shown before sign-in, so gating it would defeat the point. +// +// try_lock for the same reason quick_match uses it: a room mid-countdown +// holds its lock, and a counter must never stall a page load. A room that +// cannot be read this instant is skipped rather than waited on, so the figure +// is a lower bound under contention, never a hang. +async fn online_count(State(state): State<Arc<AppState>>) -> impl IntoResponse { + let rooms: Vec<Arc<Mutex<Room>>> = state.rooms.iter().map(|e| e.value().clone()).collect(); + let mut players = 0; + let mut counted_rooms = 0; + for room in rooms { + if let Ok(room) = room.try_lock() { + players += room.players.len(); + if !room.players.is_empty() { + counted_rooms += 1; + } + } + } + Json(OnlineResponse { players, rooms: counted_rooms }) +} + +async fn ws_handler( + ws: WebSocketUpgrade, + Path(room_code): Path<String>, + State(state): State<Arc<AppState>>, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_socket(socket, room_code, state)) +} + +async fn broadcast(room: &Room, msg: &ServerMessage) { + let Ok(text) = serde_json::to_string(msg) else { return }; + for player in room.players.values() { + let _ = player.sender.send(Message::Text(text.clone())); + } +} + +fn player_list(room: &Room) -> ServerMessage { + ServerMessage::PlayerList { + players: room.players.iter().map(|(id, p)| PlayerInfo { id: id.clone(), name: p.name.clone(), ready: p.ready }).collect(), + } +} + +async fn handle_socket(socket: WebSocket, room_code: String, state: Arc<AppState>) { + let (mut ws_tx, mut ws_rx) = socket.split(); + let player_id = uuid::Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); + + let send_task = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + if ws_tx.send(msg).await.is_err() { + break; + } + } + }); + + let room = state + .rooms + .entry(room_code.clone()) + .or_insert_with(|| Arc::new(Mutex::new(Room::new(DeviceFilter::default())))) + .clone(); + + let mut last_activity = Instant::now(); + let mut idle_check = tokio::time::interval(IDLE_CHECK_INTERVAL); + + loop { + tokio::select! { + maybe_msg = ws_rx.next() => { + match maybe_msg { + Some(Ok(Message::Text(text))) => { + last_activity = Instant::now(); + if let Ok(client_msg) = serde_json::from_str::<ClientMessage>(&text) { + handle_client_message(&room, &room_code, &state, &player_id, &tx, client_msg).await; + } + } + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + _ = idle_check.tick() => { + if last_activity.elapsed() > IDLE_TIMEOUT { + let _ = tx.send(Message::Close(None)); + break; + } + } + } + } + + { + let mut room_guard = room.lock().await; + room_guard.players.remove(&player_id); + if room_guard.players.is_empty() { + state.rooms.remove(&room_code); + } else { + let msg = player_list(&room_guard); + broadcast(&room_guard, &msg).await; + } + } + send_task.abort(); +} + +// Runs the countdown and starts the race. Split out of the Ready handler so a +// bot joining a waiting lobby can trigger a start exactly the way a human +// readying up does, and so the room lock is taken per step rather than held +// across the whole three seconds. +async fn start_race(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { + let text = { + let mut room = room_arc.lock().await; + // Another caller may have started it in the gap since the check. + if room.status != RoomStatus::Lobby { + return; + } + room.status = RoomStatus::Countdown; + let text = state + .race_texts + .get(rand::thread_rng().gen_range(0..state.race_texts.len())) + .cloned() + .unwrap_or_default(); + room.text = Some(text.clone()); + text + }; + + for seconds in (1..=3).rev() { + { + let room = room_arc.lock().await; + broadcast(&room, &ServerMessage::Countdown { seconds }).await; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + let bots: Vec<(String, f32, f32)> = { + let mut room = room_arc.lock().await; + room.status = RoomStatus::Racing; + broadcast(&room, &ServerMessage::Start { text: text.clone() }).await; + room.players + .iter() + .filter(|(_, p)| p.is_bot) + .map(|(id, p)| (id.clone(), p.target_wpm, p.target_accuracy)) + .collect() + }; + + let char_count = text.chars().count().max(1); + for (bot_id, wpm, accuracy) in bots { + tokio::spawn(drive_bot(room_arc.clone(), bot_id, wpm, accuracy, char_count)); + } +} + +// Types on a bot's behalf: converts its target pace into a share of the +// passage completed so far and broadcasts that like any other player's +// progress. Deliberately not perfectly even - a metronome-steady opponent +// reads as obviously fake. +async fn drive_bot( + room_arc: Arc<Mutex<Room>>, + bot_id: String, + target_wpm: f32, + target_accuracy: f32, + char_count: usize, +) { + let started = Instant::now(); + let mut ticker = tokio::time::interval(BOT_TICK); + // tokio's interval fires once immediately. Consumed here, because that + // first tick lands with essentially zero elapsed time and the live-WPM + // division below then reports thousands of words per minute. + ticker.tick().await; + // Progress is accumulated rather than recomputed from elapsed time, so a + // correction genuinely costs the bot ground instead of being erased by the + // next tick's arithmetic. + let mut chars_done: f32 = 0.0; + let mut correcting: u32 = 0; + // A less accurate bot stumbles more often. At 99% this is near zero; at + // 88% it is a stumble every few seconds. + let mistake_chance = ((100.0 - target_accuracy) / 100.0).clamp(0.0, 1.0) * 0.35; + + loop { + ticker.tick().await; + + if correcting > 0 { + // Backing up over a mistake: no forward progress this tick. + correcting -= 1; + } else { + if rand::thread_rng().gen_bool(mistake_chance as f64) { + correcting = BOT_CORRECTION_TICKS; + // Losing a little of what was typed, the way a real correction does. + chars_done = (chars_done - target_wpm * 0.08).max(0.0); + } else { + // Per-tick pace, wobbled so the line is not a ruler. + let wobble = 1.0 + (rand::thread_rng().gen_range(-12..=12) as f32 / 100.0); + chars_done += target_wpm * 5.0 * (BOT_TICK.as_secs_f32() / 60.0) * wobble; + } + } + + let percent = ((chars_done / char_count as f32) * 100.0).min(100.0); + let elapsed = started.elapsed().as_secs_f32(); + // Reported WPM follows what has actually been typed, so a bot that + // stalled reads slower for a while - exactly as a human would. + // Floored as well as offset by the consumed first tick: a burst of + // early progress divided by a near-zero elapsed time is what produced + // four-figure WPM readings. + let live_wpm = (chars_done / 5.0) / (elapsed.max(BOT_TICK.as_secs_f32()) / 60.0); + + let mut room = room_arc.lock().await; + // Stop if the race ended, the room emptied, or this bot was removed. + if room.status != RoomStatus::Racing || !room.players.contains_key(&bot_id) { + return; + } + if percent >= 100.0 { + if !room.finish_order.contains(&bot_id) { + room.finish_order.push(bot_id.clone()); + } + let place = room.finish_order.len() as u32; + let msg = ServerMessage::PlayerFinished { + player_id: bot_id.clone(), + wpm: live_wpm, + accuracy: target_accuracy, + time: elapsed, + place, + }; + broadcast(&room, &msg).await; + if room.finish_order.len() == room.players.len() { + room.status = RoomStatus::Finished; + } + return; + } + let msg = ServerMessage::PlayerProgress { + player_id: bot_id.clone(), + percent, + wpm: live_wpm, + }; + broadcast(&room, &msg).await; + } +} + +// Adds a bot to a quick-match room that is still sitting empty-ish, then +// readies it up - which starts the race through the same path a second human +// would have taken. +async fn maybe_add_bot(room_arc: Arc<Mutex<Room>>, state: Arc<AppState>) { + tokio::time::sleep(BOT_JOIN_DELAY).await; + + let should_start = { + let mut room = room_arc.lock().await; + // Conditions can all have changed during the wait: a real player may + // have arrived, the race may have started, everyone may have left. + if !room.allow_bots || room.status != RoomStatus::Lobby || room.human_count() == 0 { + return; + } + + // Decided in one pass rather than one bot per call: adding a bot + // readies it, which starts the race, so a second bot added afterwards + // would never make it into the lobby. + let room_for = 1 + MAX_BOTS_PER_ROOM; + let free = room_for.saturating_sub(room.players.len()); + if free == 0 { + return; + } + let wanted = rand::thread_rng().gen_range(1..=free); + + for _ in 0..wanted { + let taken: Vec<String> = room.players.values().map(|p| p.name.clone()).collect(); + let available: Vec<&&str> = BOT_NAMES + .iter() + .filter(|n| !taken.iter().any(|t| t == *n)) + .collect(); + if available.is_empty() { + break; + } + let name = available[rand::thread_rng().gen_range(0..available.len())]; + + // Take whichever tier is not already represented, so the room's + // bots never sit at similar speeds. With none present, pick at + // random. + let has_fast = room + .players + .values() + .any(|p| p.is_bot && p.target_wpm >= BOT_FAST_WPM.0); + let has_slow = room + .players + .values() + .any(|p| p.is_bot && p.target_wpm <= BOT_SLOW_WPM.1); + let fast = if has_fast { + false + } else if has_slow { + true + } else { + rand::thread_rng().gen_bool(0.5) + }; + let (lo, hi) = if fast { BOT_FAST_WPM } else { BOT_SLOW_WPM }; + + // The receiver is dropped immediately: a bot has no socket, and + // every send to it fails silently, which is what broadcast expects. + let (tx, _rx) = mpsc::unbounded_channel(); + let bot_id = format!("bot-{}", generate_room_code().to_lowercase()); + room.players.insert( + bot_id, + Player { + name: (*name).to_string(), + ready: true, + sender: tx, + is_bot: true, + target_wpm: rand::thread_rng().gen_range(lo..hi), + target_accuracy: rand::thread_rng().gen_range(BOT_MIN_ACCURACY..BOT_MAX_ACCURACY), + }, + ); + } + + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + + room.players.len() >= 2 && room.players.values().all(|p| p.ready) + }; + + if should_start { + start_race(room_arc, state).await; + } +} + +async fn handle_client_message( + room_arc: &Arc<Mutex<Room>>, + room_code: &str, + state: &Arc<AppState>, + player_id: &str, + tx: &mpsc::UnboundedSender<Message>, + msg: ClientMessage, +) { + let mut room = room_arc.lock().await; + match msg { + ClientMessage::Join { name, device_type } => { + if room.device_filter == DeviceFilter::DesktopOnly && device_type != "desktop" { + let err = ServerMessage::Error { + message: "This room is desktop-only - create or join an \"Everyone\" room from a mobile device.".to_string(), + }; + if let Ok(text) = serde_json::to_string(&err) { + let _ = tx.send(Message::Text(text)); + } + let _ = tx.send(Message::Close(None)); + return; + } + room.players.insert(player_id.to_string(), Player { name, ready: false, sender: tx.clone(), is_bot: false, target_wpm: 0.0, target_accuracy: 0.0 }); + if let Ok(text) = serde_json::to_string(&ServerMessage::Joined { player_id: player_id.to_string() }) { + let _ = tx.send(Message::Text(text)); + } + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + if room.allow_bots && room.status == RoomStatus::Lobby && room.human_count() == 1 { + tokio::spawn(maybe_add_bot(room_arc.clone(), state.clone())); + } + } + ClientMessage::Ready => { + if let Some(p) = room.players.get_mut(player_id) { + p.ready = true; + } + let list_msg = player_list(&room); + broadcast(&room, &list_msg).await; + + // Requires >=2, not just non-empty: "all ready" is vacuously true + // for a room of one, so without this a player who readies up + // before anyone else has joined immediately starts (and finishes) + // a solo race, and a genuine second player joining moments later + // arrives to a room that already left the lobby. Caught via an + // actual two-client race where connection timing wasn't + // simultaneous - exactly the ordinary case over a real network. + let should_start = room.players.len() >= 2 + && room.players.values().all(|p| p.ready) + && room.status == RoomStatus::Lobby; + // The lock is released before the countdown runs. It used to be + // held for all three seconds as a deliberate synchronisation + // point, but that also blocked every other client's messages for + // the duration - which is exactly what reset the desktop client + // mid-countdown. start_race takes the lock per step instead. + drop(room); + if should_start { + start_race(room_arc.clone(), state.clone()).await; + } + return; + } + ClientMessage::Progress { percent, wpm } => { + let msg = ServerMessage::PlayerProgress { player_id: player_id.to_string(), percent, wpm }; + broadcast(&room, &msg).await; + } + ClientMessage::Finish { wpm, accuracy, time } => { + if !room.finish_order.contains(&player_id.to_string()) { + room.finish_order.push(player_id.to_string()); + } + let place = room.finish_order.len() as u32; + let msg = ServerMessage::PlayerFinished { player_id: player_id.to_string(), wpm, accuracy, time, place }; + broadcast(&room, &msg).await; + if room.finish_order.len() == room.players.len() { + room.status = RoomStatus::Finished; + } + } + } + let _ = room_code; // kept for future room-scoped logging/metrics +} diff --git a/crates/server/src/rate_limit.rs b/crates/server/src/rate_limit.rs new file mode 100644 index 0000000..ae79775 --- /dev/null +++ b/crates/server/src/rate_limit.rs @@ -0,0 +1,34 @@ +use dashmap::DashMap; +use std::hash::Hash; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +// Fixed-window limiter, generic over whatever key makes sense for the +// endpoint (client IP for unauthenticated auth attempts, user id for an +// authenticated endpoint like stats submission). Good enough to blunt +// brute force / scripted abuse against a single-instance deployment without +// pulling in a separate crate; a multi-instance deployment would need this +// backed by a shared store (e.g. the DB or Redis) instead. +#[derive(Clone)] +pub struct RateLimiter<K: Eq + Hash + Clone + Send + Sync + 'static = std::net::IpAddr> { + hits: Arc<DashMap<K, (Instant, u32)>>, + max_attempts: u32, + window: Duration, +} + +impl<K: Eq + Hash + Clone + Send + Sync + 'static> RateLimiter<K> { + pub fn new(max_attempts: u32, window: Duration) -> Self { + Self { hits: Arc::new(DashMap::new()), max_attempts, window } + } + + /// Returns true if this key is still within its allowance and records the attempt. + pub fn check(&self, key: K) -> bool { + let now = Instant::now(); + let mut entry = self.hits.entry(key).or_insert((now, 0)); + if now.duration_since(entry.0) > self.window { + *entry = (now, 0); + } + entry.1 += 1; + entry.1 <= self.max_attempts + } +} diff --git a/crates/server/src/spotify.rs b/crates/server/src/spotify.rs new file mode 100644 index 0000000..9076afd --- /dev/null +++ b/crates/server/src/spotify.rs @@ -0,0 +1,237 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Redirect}; +use axum::routing::get; +use axum::{Json, Router}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use rand::Rng; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::Row; +use std::sync::Arc; +use time::{Duration as TimeDuration, OffsetDateTime}; + +const STATE_COOKIE: &str = "spotify_oauth_state"; +const SCOPES: &str = "user-read-currently-playing user-read-playback-state"; + +pub fn router() -> Router<Arc<AppState>> { + Router::new() + .route("/api/spotify/login", get(login)) + .route("/api/spotify/callback", get(callback)) + .route("/api/spotify/now-playing", get(now_playing)) +} + +fn random_state() -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::thread_rng(); + (0..32).map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char).collect() +} + +async fn login(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + if !state.spotify.is_configured() { + return Err(AppError::NotConfigured( + "Spotify isn't configured on this server - SPOTIFY_CLIENT_ID/SECRET are unset.".into(), + )); + } + + let csrf_state = random_state(); + let auth_url = format!( + "https://accounts.spotify.com/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}", + urlencoding_encode(&state.spotify.client_id), + urlencoding_encode(&state.spotify.redirect_uri), + urlencoding_encode(SCOPES), + urlencoding_encode(&csrf_state), + ); + + let cookie = Cookie::build((STATE_COOKIE, csrf_state)) + .http_only(true) + .secure(state.cookie_secure) + .same_site(SameSite::Lax) + .path("/api/spotify") + .max_age(TimeDuration::minutes(5)) + .build(); + + Ok((jar.add(cookie), Redirect::to(&auth_url))) +} + +#[derive(Debug, Deserialize)] +struct CallbackQuery { + code: Option<String>, + state: Option<String>, + error: Option<String>, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option<String>, + expires_in: i64, +} + +async fn callback( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Query(q): Query<CallbackQuery>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + + if let Some(err) = q.error { + return Err(AppError::InvalidInput(format!("Spotify denied the request: {err}"))); + } + let code = q.code.ok_or(AppError::InvalidInput("missing code".into()))?; + let returned_state = q.state.ok_or(AppError::InvalidInput("missing state".into()))?; + let expected_state = jar.get(STATE_COOKIE).map(|c| c.value().to_string()); + if expected_state.as_deref() != Some(returned_state.as_str()) { + return Err(AppError::InvalidInput("state mismatch - possible CSRF, try connecting again".into())); + } + + let token: TokenResponse = state + .http + .post("https://accounts.spotify.com/api/token") + .basic_auth(&state.spotify.client_id, Some(&state.spotify.client_secret)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", &state.spotify.redirect_uri), + ]) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))? + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad token response: {e}")))?; + + let Some(refresh_token) = token.refresh_token else { + return Err(AppError::Internal(anyhow::anyhow!("Spotify did not return a refresh token"))); + }; + let expires_at = format_timestamp(OffsetDateTime::now_utc() + TimeDuration::seconds(token.expires_in)); + + sqlx::query( + "INSERT INTO spotify_tokens (user_id, access_token, refresh_token, expires_at) VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET access_token = excluded.access_token, refresh_token = excluded.refresh_token, expires_at = excluded.expires_at", + ) + .bind(&user.id) + .bind(&token.access_token) + .bind(&refresh_token) + .bind(&expires_at) + .execute(&state.db) + .await?; + + let jar = jar.remove(Cookie::from(STATE_COOKIE)); + Ok((jar, Redirect::to(&state.frontend_origin))) +} + +async fn get_valid_access_token(state: &AppState, user_id: &str) -> Result<Option<String>, AppError> { + let row = sqlx::query("SELECT access_token, refresh_token, expires_at FROM spotify_tokens WHERE user_id = ?") + .bind(user_id) + .fetch_optional(&state.db) + .await?; + let Some(row) = row else { return Ok(None) }; + + let access_token: String = row.try_get("access_token").map_err(|e| AppError::Internal(e.into()))?; + let refresh_token: String = row.try_get("refresh_token").map_err(|e| AppError::Internal(e.into()))?; + let expires_at: String = row.try_get("expires_at").map_err(|e| AppError::Internal(e.into()))?; + + let expired = OffsetDateTime::parse(&expires_at, &time::format_description::well_known::Rfc3339) + .map(|t| t < OffsetDateTime::now_utc()) + .unwrap_or(true); + + if !expired { + return Ok(Some(access_token)); + } + + // Access tokens are short-lived (Spotify: ~1 hour) - refreshed + // transparently here so the caller never has to think about expiry. + let refreshed: TokenResponse = state + .http + .post("https://accounts.spotify.com/api/token") + .basic_auth(&state.spotify.client_id, Some(&state.spotify.client_secret)) + .form(&[("grant_type", "refresh_token"), ("refresh_token", &refresh_token)]) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))? + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("bad refresh response: {e}")))?; + + let new_refresh_token = refreshed.refresh_token.unwrap_or(refresh_token); + let new_expires_at = format_timestamp(OffsetDateTime::now_utc() + TimeDuration::seconds(refreshed.expires_in)); + sqlx::query("UPDATE spotify_tokens SET access_token = ?, refresh_token = ?, expires_at = ? WHERE user_id = ?") + .bind(&refreshed.access_token) + .bind(&new_refresh_token) + .bind(&new_expires_at) + .bind(user_id) + .execute(&state.db) + .await?; + + Ok(Some(refreshed.access_token)) +} + +#[derive(Debug, Serialize)] +struct NowPlaying { + is_playing: bool, + track: Option<String>, + artist: Option<String>, + duration_ms: Option<u64>, + progress_ms: Option<u64>, +} + +impl NowPlaying { + fn nothing() -> Self { + Self { is_playing: false, track: None, artist: None, duration_ms: None, progress_ms: None } + } +} + +async fn now_playing(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + let Some(access_token) = get_valid_access_token(&state, &user.id).await? else { + return Err(AppError::NotFound); // not connected to Spotify + }; + + let res = state + .http + .get("https://api.spotify.com/v1/me/player/currently-playing") + .bearer_auth(&access_token) + .send() + .await + .map_err(|e| AppError::Internal(e.into()))?; + + if res.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(Json(NowPlaying::nothing())); + } + if !res.status().is_success() { + return Ok(Json(NowPlaying::nothing())); + } + + let body: Value = res.json().await.map_err(|e| AppError::Internal(e.into()))?; + let is_playing = body.get("is_playing").and_then(Value::as_bool).unwrap_or(false); + let item = body.get("item"); + let track = item.and_then(|i| i.get("name")).and_then(Value::as_str).map(str::to_string); + let artist = item + .and_then(|i| i.get("artists")) + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|a| a.get("name")) + .and_then(Value::as_str) + .map(str::to_string); + let duration_ms = item.and_then(|i| i.get("duration_ms")).and_then(Value::as_u64); + let progress_ms = body.get("progress_ms").and_then(Value::as_u64); + + Ok(Json(NowPlaying { is_playing, track, artist, duration_ms, progress_ms })) +} + +// Minimal percent-encoding - avoids pulling in the `url` crate just to +// build a couple of query string values for the authorize URL. +fn urlencoding_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs new file mode 100644 index 0000000..267b6fb --- /dev/null +++ b/crates/server/src/state.rs @@ -0,0 +1,65 @@ +use crate::multiplayer::{new_registry, RoomRegistry}; +use crate::rate_limit::RateLimiter; +use reqwest::Client; +use sqlx::SqlitePool; +use std::time::Duration; + +#[derive(Clone, Default)] +pub struct SpotifyConfig { + pub client_id: String, + pub client_secret: String, + pub redirect_uri: String, +} + +impl SpotifyConfig { + pub fn is_configured(&self) -> bool { + !self.client_id.is_empty() && !self.client_secret.is_empty() + } +} + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub auth_rate_limiter: RateLimiter, + /// Keyed by user id, not IP - this guards an authenticated endpoint + /// (stats submission) against a single compromised/scripted account + /// hammering it, which an IP-keyed limiter wouldn't catch behind NAT or + /// a VPN and would over-punish for a shared IP. + pub stats_rate_limiter: RateLimiter<String>, + /// Set from COOKIE_SECURE. Off for plain-HTTP local dev, must be on + /// behind TLS in production or browsers silently drop the cookie. + pub cookie_secure: bool, + pub rooms: RoomRegistry, + /// Race passages every multiplayer room draws from, so every player in + /// a room types the identical text - loaded once at startup rather + /// than per-room, since the pool itself never changes at runtime. + pub race_texts: Vec<String>, + pub spotify: SpotifyConfig, + pub frontend_origin: String, + pub http: Client, +} + +impl AppState { + pub fn new( + db: SqlitePool, + cookie_secure: bool, + race_texts: Vec<String>, + spotify: SpotifyConfig, + frontend_origin: String, + ) -> Self { + Self { + db, + 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 + // rapid Words-10 sessions while still capping scripted spam. + stats_rate_limiter: RateLimiter::new(60, Duration::from_secs(5 * 60)), + cookie_secure, + rooms: new_registry(), + race_texts, + spotify, + frontend_origin, + http: Client::new(), + } + } +} diff --git a/crates/server/src/stats.rs b/crates/server/src/stats.rs new file mode 100644 index 0000000..6e80a3d --- /dev/null +++ b/crates/server/src/stats.rs @@ -0,0 +1,275 @@ +use crate::auth::{current_user, format_timestamp}; +use crate::error::AppError; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::response::IntoResponse; +use axum::routing::{get, post}; +use axum::{Json, Router}; +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/stats", post(submit_result)) + .route("/api/stats/me", get(my_stats)) + .route("/api/leaderboard", get(leaderboard)) + .route("/api/users/:username/public", get(public_profile)) +} + +#[derive(Debug, Deserialize)] +pub struct SubmitResult { + pub mode_key: String, + pub wpm: f64, + pub raw_wpm: f64, + pub accuracy: f64, + pub time_seconds: f64, + #[serde(default)] + pub device_type: Option<String>, + /// Milliseconds between consecutive keystrokes, in order. Optional -- + /// older/other clients that don't send it just skip the timing-variance + /// half of the anti-cheat check (see anticheat::should_flag). + #[serde(default)] + pub keystroke_intervals_ms: Option<Vec<f64>>, +} + +// Bounds a client could never legitimately produce - rejecting them keeps +// obviously-fabricated rows out of personal bests and the public +// leaderboard without trying to fully police what "legitimate" means. +fn validate_result(r: &SubmitResult) -> Result<(), AppError> { + if r.mode_key.is_empty() || r.mode_key.len() > 64 { + return Err(AppError::InvalidInput("invalid mode_key".into())); + } + if !(0.0..=500.0).contains(&r.wpm) || !(0.0..=500.0).contains(&r.raw_wpm) { + return Err(AppError::InvalidInput("wpm out of range".into())); + } + if !(0.0..=100.0).contains(&r.accuracy) { + return Err(AppError::InvalidInput("accuracy out of range".into())); + } + if !(0.0..3600.0).contains(&r.time_seconds) { + return Err(AppError::InvalidInput("time_seconds out of range".into())); + } + if let Some(d) = &r.device_type { + if d != "desktop" && d != "mobile" { + return Err(AppError::InvalidInput("device_type must be 'desktop' or 'mobile'".into())); + } + } + Ok(()) +} + +async fn submit_result( + State(state): State<Arc<AppState>>, + jar: CookieJar, + Json(body): Json<SubmitResult>, +) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + if !state.stats_rate_limiter.check(user.id.clone()) { + return Err(AppError::RateLimited); + } + validate_result(&body)?; + + let device_type = body.device_type.as_deref().unwrap_or("desktop"); + let flagged = crate::anticheat::should_flag(body.wpm, &body.keystroke_intervals_ms); + + sqlx::query( + "INSERT INTO test_results (id, user_id, mode_key, wpm, raw_wpm, accuracy, time_seconds, created_at, device_type, flagged) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&user.id) + .bind(&body.mode_key) + .bind(body.wpm) + .bind(body.raw_wpm) + .bind(body.accuracy) + .bind(body.time_seconds) + .bind(format_timestamp(OffsetDateTime::now_utc())) + .bind(device_type) + .bind(flagged) + .execute(&state.db) + .await?; + + Ok(axum::http::StatusCode::NO_CONTENT) +} + +#[derive(Debug, Serialize)] +pub struct PersonalBest { + pub mode_key: String, + pub wpm: f64, + pub date: String, +} + +#[derive(Debug, Serialize)] +pub struct MyStats { + pub tests_completed: i64, + pub total_time_seconds: f64, + pub average_wpm: f64, + pub average_accuracy: f64, + pub best_wpm: f64, + pub personal_bests: Vec<PersonalBest>, +} + +// Shared by /api/stats/me (the signed-in caller's own id) and the public +// profile endpoint (an arbitrary user_id looked up by username) - same +// aggregate, same window-function best-per-mode query either way. +async fn fetch_stats_summary(state: &AppState, user_id: &str) -> Result<MyStats, AppError> { + let summary = sqlx::query( + "SELECT COUNT(*) as tests_completed, + COALESCE(SUM(time_seconds), 0) as total_time_seconds, + COALESCE(AVG(wpm), 0) as average_wpm, + COALESCE(AVG(accuracy), 0) as average_accuracy, + COALESCE(MAX(wpm), 0) as best_wpm + FROM test_results WHERE user_id = ?", + ) + .bind(user_id) + .fetch_one(&state.db) + .await?; + + // Window function picks each mode's single highest-wpm row (and that + // row's own date) rather than independently maxing wpm and date, which + // could otherwise pair a best score with the date of a different run. + let bests = sqlx::query( + "SELECT mode_key, wpm, created_at FROM ( + SELECT mode_key, wpm, created_at, + ROW_NUMBER() OVER (PARTITION BY mode_key ORDER BY wpm DESC) as rn + FROM test_results WHERE user_id = ? + ) WHERE rn = 1", + ) + .bind(user_id) + .fetch_all(&state.db) + .await?; + + let personal_bests = bests + .into_iter() + .map(|row| PersonalBest { + mode_key: row.try_get("mode_key").unwrap_or_default(), + wpm: row.try_get("wpm").unwrap_or_default(), + date: row.try_get("created_at").unwrap_or_default(), + }) + .collect(); + + Ok(MyStats { + tests_completed: summary.try_get("tests_completed").unwrap_or_default(), + total_time_seconds: summary.try_get("total_time_seconds").unwrap_or_default(), + average_wpm: summary.try_get("average_wpm").unwrap_or_default(), + average_accuracy: summary.try_get("average_accuracy").unwrap_or_default(), + best_wpm: summary.try_get("best_wpm").unwrap_or_default(), + personal_bests, + }) +} + +async fn my_stats(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> { + let user = current_user(&state.db, &jar).await.ok_or(AppError::Unauthorized)?; + Ok(Json(fetch_stats_summary(&state, &user.id).await?)) +} + +#[derive(Debug, Serialize)] +pub struct PublicProfile { + pub username: String, + pub joined_at: String, + pub flair: Option<String>, + #[serde(flatten)] + pub stats: MyStats, +} + +// Deliberately excludes anything not meant for other people to see (no +// email, no password hash, no session/friendship data) - just the same +// aggregate/personal-bests shape /api/stats/me returns for yourself, plus +// the username, join date, and equipped flair (cosmetics only mean anything +// if other people can actually see them), for anyone looking someone up. +async fn public_profile(State(state): State<Arc<AppState>>, Path(username): Path<String>) -> Result<impl IntoResponse, AppError> { + // LEFT JOIN cosmetics for the flair's *value* ("star"), not the raw + // cosmetic id ("flair-star") stored on the user row - the frontend's + // FLAIR_ICONS map is keyed by value, and hardcoding the id->value + // mapping client-side would silently drift the moment the catalog + // changes. + let row = sqlx::query( + "SELECT users.id as id, users.created_at as created_at, cosmetics.value as flair_value + FROM users LEFT JOIN cosmetics ON cosmetics.id = users.equipped_flair + WHERE users.username = ?", + ) + .bind(&username) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + + let user_id: String = row.try_get("id").map_err(|e| AppError::Internal(e.into()))?; + let joined_at: String = row.try_get("created_at").map_err(|e| AppError::Internal(e.into()))?; + // See cosmetics.rs's comment on this exact pattern - .ok() alone left + // T ambiguous enough to decode NULL as "" instead of None. + let flair: Option<String> = row.try_get::<Option<String>, _>("flair_value").unwrap_or(None); + let stats = fetch_stats_summary(&state, &user_id).await?; + + Ok(Json(PublicProfile { username, joined_at, flair, stats })) +} + +#[derive(Debug, Deserialize)] +pub struct LeaderboardQuery { + pub mode: String, + pub limit: Option<u32>, + /// "desktop" to show desktop-only results; omitted/anything else means + /// no device filter. Mobile is intentionally still counted by default -- + /// this is an opt-in filter for viewers who want a keyboard-only board, + /// not a default exclusion of mobile players. + pub device: Option<String>, +} + +#[derive(Debug, Serialize)] +pub struct LeaderboardEntry { + pub username: String, + pub wpm: f64, + pub accuracy: f64, + pub date: String, + pub device_type: String, + pub flair: Option<String>, +} + +async fn leaderboard(State(state): State<Arc<AppState>>, Query(q): Query<LeaderboardQuery>) -> Result<impl IntoResponse, AppError> { + if q.mode.is_empty() || q.mode.len() > 64 { + return Err(AppError::InvalidInput("invalid mode".into())); + } + let limit = q.limit.unwrap_or(50).clamp(1, 100); + let desktop_only = q.device.as_deref() == Some("desktop"); + + // One row per user: their single best NON-FLAGGED run in this mode, + // ranked by wpm. A flagged run doesn't just rank lower - it's excluded + // entirely, since a flagged score sitting on the board (even far down) + // is still a false signal to everyone who sees it. + // Same reasoning as public_profile: join through to the flair's value, + // not the raw cosmetic id. + let rows = sqlx::query( + "SELECT username, wpm, accuracy, created_at, device_type, flair_value FROM ( + SELECT users.username as username, test_results.wpm as wpm, + test_results.accuracy as accuracy, test_results.created_at as created_at, + test_results.device_type as device_type, cosmetics.value as flair_value, + ROW_NUMBER() OVER (PARTITION BY test_results.user_id ORDER BY test_results.wpm DESC) as rn + FROM test_results + JOIN users ON users.id = test_results.user_id + LEFT JOIN cosmetics ON cosmetics.id = users.equipped_flair + WHERE test_results.mode_key = ? AND test_results.flagged = 0 + AND (? = 0 OR test_results.device_type = 'desktop') + ) WHERE rn = 1 + ORDER BY wpm DESC + LIMIT ?", + ) + .bind(&q.mode) + .bind(desktop_only) + .bind(limit) + .fetch_all(&state.db) + .await?; + + let entries: Vec<LeaderboardEntry> = rows + .into_iter() + .map(|row| LeaderboardEntry { + username: row.try_get("username").unwrap_or_default(), + wpm: row.try_get("wpm").unwrap_or_default(), + accuracy: row.try_get("accuracy").unwrap_or_default(), + date: row.try_get("created_at").unwrap_or_default(), + device_type: row.try_get("device_type").unwrap_or_default(), + flair: row.try_get::<Option<String>, _>("flair_value").unwrap_or(None), + }) + .collect(); + + Ok(Json(entries)) +} |