fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+54 -53
View File
@@ -1,22 +1,17 @@
//! Authentication handlers: register, login, refresh, delete account,
//! current-user profile, and avatar upload.
use axum::{
body::Bytes,
extract::State,
http::HeaderMap,
Json,
};
use axum::{Json, body::Bytes, extract::State, http::HeaderMap};
use bcrypt::{hash, verify};
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
use jsonwebtoken::{EncodingKey, Header, encode};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
error::AppError,
middleware::{validate_refresh_token, AuthenticatedUser, Claims},
AppState,
error::AppError,
middleware::{AuthenticatedUser, Claims, validate_refresh_token},
};
// ---------------------------------------------------------------------------
@@ -92,8 +87,12 @@ pub fn make_access_token(user_id: &str, secret: &str) -> Result<String, AppError
kind: "access".to_string(),
jti: None,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| AppError::Internal(e.to_string()))
}
/// Encode a JWT refresh token (30-day expiry) for `user_id`.
@@ -109,8 +108,12 @@ pub fn make_refresh_token(user_id: &str, secret: &str) -> Result<(String, String
kind: "refresh".to_string(),
jti: Some(jti.clone()),
};
let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()))
.map_err(|e| AppError::Internal(e.to_string()))?;
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok((token, jti))
}
@@ -176,13 +179,11 @@ pub async fn register(
// Check for duplicate username. SQLite returns TEXT as nullable so we
// flatten the Option<Option<String>> produced by fetch_optional.
let existing: Option<String> = sqlx::query_scalar!(
"SELECT id FROM users WHERE username = ?",
username
)
.fetch_optional(&state.pool)
.await?
.flatten();
let existing: Option<String> =
sqlx::query_scalar!("SELECT id FROM users WHERE username = ?", username)
.fetch_optional(&state.pool)
.await?
.flatten();
if existing.is_some() {
tracing::warn!(username = %username, "register: username already taken");
@@ -228,8 +229,12 @@ pub async fn login(
.await?;
let row = row.ok_or(AppError::InvalidCredentials)?;
let row_id = row.id.ok_or_else(|| AppError::Internal("user id missing".into()))?;
let row_hash = row.password_hash.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
let row_id = row
.id
.ok_or_else(|| AppError::Internal("user id missing".into()))?;
let row_hash = row
.password_hash
.ok_or_else(|| AppError::Internal("password hash missing".into()))?;
let valid = verify(&body.password, &row_hash)?;
if !valid {
@@ -266,13 +271,11 @@ pub async fn refresh(
// Verify this jti is still live (not yet consumed or from a deleted account).
// SQLite TEXT columns are always nullable in sqlx; flatten the double-Option.
let exists: Option<String> = sqlx::query_scalar!(
"SELECT jti FROM refresh_tokens WHERE jti = ?",
jti
)
.fetch_optional(&state.pool)
.await?
.flatten();
let exists: Option<String> =
sqlx::query_scalar!("SELECT jti FROM refresh_tokens WHERE jti = ?", jti)
.fetch_optional(&state.pool)
.await?
.flatten();
if exists.is_none() {
return Err(AppError::Unauthorized);
@@ -361,12 +364,14 @@ pub async fn upload_avatar(
.to_string();
let ext = match mime.as_str() {
"image/jpeg" | "image/jpg" => "jpg",
"image/png" => "png",
"image/png" => "png",
"image/webp" => "webp",
"image/gif" => "gif",
_ => return Err(AppError::BadRequest(
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
)),
"image/gif" => "gif",
_ => {
return Err(AppError::BadRequest(
"avatar must be image/jpeg, image/png, image/webp, or image/gif".into(),
));
}
};
if body.len() > AVATAR_MAX_BYTES {
return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into()));
@@ -402,12 +407,10 @@ pub async fn upload_avatar(
.execute(&state.pool)
.await?;
let username: Option<String> = sqlx::query_scalar!(
"SELECT username FROM users WHERE id = ?",
user.user_id
)
.fetch_optional(&state.pool)
.await?;
let username: Option<String> =
sqlx::query_scalar!("SELECT username FROM users WHERE id = ?", user.user_id)
.fetch_optional(&state.pool)
.await?;
Ok(Json(MeResponse {
id: user.user_id,
@@ -442,13 +445,11 @@ pub async fn reset_password(
)));
}
let user_id: Option<String> = sqlx::query_scalar!(
"SELECT id FROM users WHERE username = ?",
username
)
.fetch_optional(pool)
.await?
.flatten();
let user_id: Option<String> =
sqlx::query_scalar!("SELECT id FROM users WHERE username = ?", username)
.fetch_optional(pool)
.await?
.flatten();
let user_id =
user_id.ok_or_else(|| AppError::NotFound(format!("user '{username}' not found")))?;
@@ -475,7 +476,7 @@ pub async fn reset_password(
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{DecodingKey, Validation, decode};
const TEST_SECRET: &str = "test_secret_for_unit_tests_only";
@@ -556,11 +557,11 @@ mod tests {
#[test]
fn username_chars_ok_rejects_special_chars() {
assert!(!username_chars_ok("ali ce")); // space
assert!(!username_chars_ok("ali-ce")); // hyphen
assert!(!username_chars_ok("ali.ce")); // dot
assert!(!username_chars_ok("ali@ce")); // at
assert!(!username_chars_ok("ali!ce")); // exclamation
assert!(!username_chars_ok("ali ce")); // space
assert!(!username_chars_ok("ali-ce")); // hyphen
assert!(!username_chars_ok("ali.ce")); // dot
assert!(!username_chars_ok("ali@ce")); // at
assert!(!username_chars_ok("ali!ce")); // exclamation
}
#[test]
+24 -8
View File
@@ -6,12 +6,12 @@
//! generated on first request for that date, then stored in the database
//! so subsequent calls return the same value.
use axum::{extract::State, Json};
use axum::{Json, extract::State};
use chrono::Utc;
use solitaire_sync::ChallengeGoal;
use crate::{error::AppError, AppState};
use crate::{AppState, error::AppError};
// ---------------------------------------------------------------------------
// Seed generation
@@ -115,7 +115,9 @@ pub async fn daily_challenge(
.await?;
if let Some(r) = row {
let json = r.goal_json.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
let json = r
.goal_json
.ok_or_else(|| AppError::Internal("missing goal_json".into()))?;
let goal: ChallengeGoal = serde_json::from_str(&json)?;
return Ok(Json(goal));
}
@@ -148,7 +150,9 @@ pub async fn daily_challenge(
.fetch_one(&state.pool)
.await?;
let stored_json = stored.goal_json.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
let stored_json = stored
.goal_json
.ok_or_else(|| AppError::Internal("missing goal_json after insert".into()))?;
let stored_goal: ChallengeGoal = serde_json::from_str(&stored_json)?;
Ok(Json(stored_goal))
}
@@ -165,13 +169,22 @@ mod tests {
#[test]
fn hash_date_differs_across_adjacent_days() {
assert_ne!(hash_date_to_u64("2026-04-26"), hash_date_to_u64("2026-04-27"));
assert_ne!(hash_date_to_u64("2026-04-26"), hash_date_to_u64("2026-04-25"));
assert_ne!(
hash_date_to_u64("2026-04-26"),
hash_date_to_u64("2026-04-27")
);
assert_ne!(
hash_date_to_u64("2026-04-26"),
hash_date_to_u64("2026-04-25")
);
}
#[test]
fn hash_date_differs_across_years() {
assert_ne!(hash_date_to_u64("2026-01-01"), hash_date_to_u64("2027-01-01"));
assert_ne!(
hash_date_to_u64("2026-01-01"),
hash_date_to_u64("2027-01-01")
);
}
#[test]
@@ -217,7 +230,10 @@ mod tests {
fn generate_goal_all_variants_have_sane_ranges() {
for variant_idx in 0u64..6 {
let g = generate_goal("2026-04-26", variant_idx);
assert!(!g.description.is_empty(), "variant {variant_idx}: description must not be empty");
assert!(
!g.description.is_empty(),
"variant {variant_idx}: description must not be empty"
);
if let Some(t) = g.max_time_secs {
assert!(
(60..=3600).contains(&t),
+17 -5
View File
@@ -1,9 +1,9 @@
//! Application-level error type with automatic HTTP response conversion.
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
@@ -63,19 +63,31 @@ impl IntoResponse for AppError {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Database(e) => {
tracing::error!("database error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
AppError::BcryptError(e) => {
tracing::error!("bcrypt error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
AppError::Json(e) => {
tracing::error!("json error: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
AppError::Internal(msg) => {
tracing::error!("internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string())
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
};
+24 -10
View File
@@ -3,13 +3,13 @@
//! `GET /api/leaderboard` — list all opted-in entries (requires auth).
//! `POST /api/leaderboard/opt-in` — opt in and set / update display name.
use axum::{extract::State, Json};
use axum::{Json, extract::State};
use chrono::Utc;
use serde::Deserialize;
use solitaire_sync::LeaderboardEntry;
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Request shapes
@@ -118,7 +118,9 @@ pub async fn opt_in(
) -> Result<Json<serde_json::Value>, AppError> {
let display_name = body.display_name.trim();
if display_name.is_empty() {
return Err(AppError::BadRequest("display_name must not be empty".into()));
return Err(AppError::BadRequest(
"display_name must not be empty".into(),
));
}
if display_name.chars().count() > DISPLAY_NAME_MAX {
return Err(AppError::BadRequest(format!(
@@ -197,9 +199,9 @@ mod tests {
fn leaderboard_entries_sorted_by_score_descending() {
let mut entries = [
entry("Charlie", Some(1_200)),
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
entry("Alice", Some(8_000)),
entry("Bob", Some(3_500)),
entry("Dave", None), // no score — should rank last
];
// Mirrors the SQL sort:
@@ -214,10 +216,22 @@ mod tests {
});
// Scored entries first, in descending order.
assert_eq!(entries[0].display_name, "Alice", "highest scorer must be first");
assert_eq!(entries[1].display_name, "Bob", "second-highest scorer must be second");
assert_eq!(entries[2].display_name, "Charlie", "lowest scorer must be third");
assert_eq!(
entries[0].display_name, "Alice",
"highest scorer must be first"
);
assert_eq!(
entries[1].display_name, "Bob",
"second-highest scorer must be second"
);
assert_eq!(
entries[2].display_name, "Charlie",
"lowest scorer must be third"
);
// Null-score entry sinks to the bottom.
assert_eq!(entries[3].display_name, "Dave", "entry with no score must rank last");
assert_eq!(
entries[3].display_name, "Dave",
"entry with no score must rank last"
);
}
}
+6 -14
View File
@@ -15,22 +15,22 @@ pub mod sync;
pub use auth::reset_password;
use axum::{
Router,
extract::DefaultBodyLimit,
http::{HeaderValue, Request},
middleware as axum_middleware,
response::{Html, Response},
routing::{delete, get, post, put},
Router,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{DecodingKey, Validation, decode};
use sqlx::SqlitePool;
use std::sync::Arc;
use tower_governor::{
GovernorLayer,
errors::GovernorError,
governor::GovernorConfigBuilder,
key_extractor::{KeyExtractor, SmartIpKeyExtractor},
GovernorLayer,
};
use tower_http::services::ServeDir;
@@ -59,9 +59,7 @@ impl KeyExtractor for UserIdKeyExtractor {
return Ok(user_id);
}
// Fall back to IP so unauthenticated bursts don't bypass throttling.
SmartIpKeyExtractor
.extract(req)
.map(|ip| ip.to_string())
SmartIpKeyExtractor.extract(req).map(|ip| ip.to_string())
}
}
@@ -258,18 +256,12 @@ const CSP: &str = concat!(
async fn security_headers(req: Request<axum::body::Body>, next: axum_middleware::Next) -> Response {
let mut res = next.run(req).await;
let headers = res.headers_mut();
headers.insert(
"Content-Security-Policy",
HeaderValue::from_static(CSP),
);
headers.insert("Content-Security-Policy", HeaderValue::from_static(CSP));
headers.insert(
"X-Content-Type-Options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"X-Frame-Options",
HeaderValue::from_static("DENY"),
);
headers.insert("X-Frame-Options", HeaderValue::from_static("DENY"));
res
}
+4 -6
View File
@@ -31,12 +31,12 @@
//! echo "new_password" | ./solitaire_server --reset-password alice
//! ```
use solitaire_server::{build_router, AppState};
use sqlx::{sqlite::SqliteConnectOptions, SqlitePool};
use solitaire_server::{AppState, build_router};
use sqlx::{SqlitePool, sqlite::SqliteConnectOptions};
use std::{
io::{self, BufRead},
str::FromStr,
net::SocketAddr,
str::FromStr,
};
#[tokio::main]
@@ -135,7 +135,5 @@ async fn run_server() {
.await
.expect("failed to bind TCP listener");
axum::serve(listener, app)
.await
.expect("server error");
axum::serve(listener, app).await.expect("server error");
}
+32 -16
View File
@@ -10,10 +10,10 @@ use axum::{
middleware::Next,
response::Response,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use jsonwebtoken::{DecodingKey, Validation, decode};
use serde::{Deserialize, Serialize};
use crate::{error::AppError, AppState};
use crate::{AppState, error::AppError};
/// The claims encoded in our JWTs.
#[derive(Debug, Serialize, Deserialize)]
@@ -50,8 +50,7 @@ pub async fn require_auth(
mut req: Request,
next: Next,
) -> Result<Response, AppError> {
let token = extract_bearer_token(req.headers())
.ok_or(AppError::Unauthorized)?;
let token = extract_bearer_token(req.headers()).ok_or(AppError::Unauthorized)?;
let claims = validate_access_token(&token, &state.jwt_secret)?;
@@ -75,8 +74,7 @@ pub fn validate_access_token(token: &str, secret: &str) -> Result<Claims, AppErr
let mut validation = Validation::default();
validation.validate_exp = true;
let data = decode::<Claims>(token, &key, &validation)
.map_err(|_| AppError::Unauthorized)?;
let data = decode::<Claims>(token, &key, &validation).map_err(|_| AppError::Unauthorized)?;
if data.claims.kind != "access" {
return Err(AppError::Unauthorized);
@@ -91,8 +89,7 @@ pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppEr
let mut validation = Validation::default();
validation.validate_exp = true;
let data = decode::<Claims>(token, &key, &validation)
.map_err(|_| AppError::Unauthorized)?;
let data = decode::<Claims>(token, &key, &validation).map_err(|_| AppError::Unauthorized)?;
if data.claims.kind != "refresh" {
return Err(AppError::Unauthorized);
@@ -129,7 +126,7 @@ mod tests {
use super::*;
use axum::http::{HeaderMap, HeaderValue};
use chrono::Utc;
use jsonwebtoken::{encode, EncodingKey, Header};
use jsonwebtoken::{EncodingKey, Header, encode};
const SECRET: &str = "test_secret_for_middleware_unit_tests_only";
@@ -141,7 +138,12 @@ mod tests {
kind: kind.to_string(),
jti: None,
};
encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_bytes())).unwrap()
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(SECRET.as_bytes()),
)
.unwrap()
}
// -----------------------------------------------------------------------
@@ -155,7 +157,10 @@ mod tests {
"Authorization",
HeaderValue::from_static("Bearer my.jwt.token"),
);
assert_eq!(extract_bearer_token(&headers), Some("my.jwt.token".to_string()));
assert_eq!(
extract_bearer_token(&headers),
Some("my.jwt.token".to_string())
);
}
#[test]
@@ -188,7 +193,8 @@ mod tests {
#[test]
fn validate_access_token_accepts_valid_access_token() {
let token = make_token("user-abc", "access", 3600);
let claims = validate_access_token(&token, SECRET).expect("should accept valid access token");
let claims =
validate_access_token(&token, SECRET).expect("should accept valid access token");
assert_eq!(claims.sub, "user-abc");
assert_eq!(claims.kind, "access");
}
@@ -197,7 +203,10 @@ mod tests {
fn validate_access_token_rejects_refresh_token() {
let token = make_token("user-abc", "refresh", 3600);
let result = validate_access_token(&token, SECRET);
assert!(result.is_err(), "refresh token must be rejected by access validator");
assert!(
result.is_err(),
"refresh token must be rejected by access validator"
);
}
#[test]
@@ -212,7 +221,10 @@ mod tests {
fn validate_access_token_rejects_wrong_secret() {
let token = make_token("user-abc", "access", 3600);
let result = validate_access_token(&token, "wrong_secret");
assert!(result.is_err(), "token signed with different secret must be rejected");
assert!(
result.is_err(),
"token signed with different secret must be rejected"
);
}
// -----------------------------------------------------------------------
@@ -222,7 +234,8 @@ mod tests {
#[test]
fn validate_refresh_token_accepts_valid_refresh_token() {
let token = make_token("user-xyz", "refresh", 86400);
let claims = validate_refresh_token(&token, SECRET).expect("should accept valid refresh token");
let claims =
validate_refresh_token(&token, SECRET).expect("should accept valid refresh token");
assert_eq!(claims.sub, "user-xyz");
assert_eq!(claims.kind, "refresh");
}
@@ -231,7 +244,10 @@ mod tests {
fn validate_refresh_token_rejects_access_token() {
let token = make_token("user-xyz", "access", 86400);
let result = validate_refresh_token(&token, SECRET);
assert!(result.is_err(), "access token must be rejected by refresh validator");
assert!(
result.is_err(),
"access token must be rejected by refresh validator"
);
}
#[test]
+42 -8
View File
@@ -15,14 +15,49 @@
//! be served without scanning every blob.
use axum::{
extract::{Path, Query, State},
Json,
extract::{Path, Query, State},
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
const KNOWN_MODES: &[&str] = &["Classic", "Zen", "TimeAttack", "Challenge", "Difficulty"];
const KNOWN_DRAW_MODES: &[&str] = &["DrawOne", "DrawThree"];
fn validate_header(h: &ReplayHeader) -> Result<(), AppError> {
if !KNOWN_DRAW_MODES.contains(&h.draw_mode.as_str()) {
return Err(AppError::BadRequest(format!(
"invalid draw_mode '{}'; expected one of {:?}",
h.draw_mode, KNOWN_DRAW_MODES
)));
}
if !KNOWN_MODES.contains(&h.mode.as_str()) {
return Err(AppError::BadRequest(format!(
"invalid mode '{}'; expected one of {:?}",
h.mode, KNOWN_MODES
)));
}
if h.time_seconds <= 0 || h.time_seconds > 86_400 {
return Err(AppError::BadRequest(format!(
"time_seconds {} out of range (186400)",
h.time_seconds
)));
}
if h.final_score < 0 || h.final_score > 1_000_000 {
return Err(AppError::BadRequest(format!(
"final_score {} out of range (01000000)",
h.final_score
)));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Wire types
@@ -91,6 +126,8 @@ pub async fn upload(
let header: ReplayHeader = serde_json::from_value(payload.clone())
.map_err(|e| AppError::BadRequest(format!("replay JSON missing fields: {e}")))?;
validate_header(&header)?;
let id = Uuid::new_v4().to_string();
let received_at = Utc::now().to_rfc3339();
let replay_json = serde_json::to_string(&payload)?;
@@ -205,12 +242,9 @@ pub async fn get_by_id(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let row = sqlx::query!(
"SELECT replay_json FROM replays WHERE id = ?",
id,
)
.fetch_optional(&state.pool)
.await?;
let row = sqlx::query!("SELECT replay_json FROM replays WHERE id = ?", id,)
.fetch_optional(&state.pool)
.await?;
let replay_json = row
.ok_or_else(|| AppError::NotFound("replay not found".into()))?
+35 -17
View File
@@ -3,15 +3,15 @@
//! `GET /api/sync/pull` — return the server's stored payload for this user.
//! `POST /api/sync/push` — receive the client's payload, merge, store, return.
use axum::{extract::State, Json};
use axum::{Json, extract::State};
use chrono::Utc;
use sqlx::SqlitePool;
use solitaire_sync::{
merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse,
AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge,
};
use crate::{error::AppError, middleware::AuthenticatedUser, AppState};
use crate::{AppState, error::AppError, middleware::AuthenticatedUser};
// ---------------------------------------------------------------------------
// Database row helpers
@@ -38,11 +38,17 @@ async fn load_sync_row(pool: &SqlitePool, user_id: &str) -> Result<Option<SyncRo
/// Deserialize a stored `SyncRow` into a `SyncPayload`.
fn row_to_payload(row: &SyncRow, user_id: &str) -> Result<SyncPayload, AppError> {
let stats_json = row.stats_json.as_deref()
let stats_json = row
.stats_json
.as_deref()
.ok_or_else(|| AppError::Internal("missing stats_json".into()))?;
let achievements_json = row.achievements_json.as_deref()
let achievements_json = row
.achievements_json
.as_deref()
.ok_or_else(|| AppError::Internal("missing achievements_json".into()))?;
let progress_json = row.progress_json.as_deref()
let progress_json = row
.progress_json
.as_deref()
.ok_or_else(|| AppError::Internal("missing progress_json".into()))?;
let stats: StatsSnapshot = serde_json::from_str(stats_json)?;
@@ -172,28 +178,29 @@ pub async fn push(
/// If the user is opted in to the leaderboard, update their row with the
/// better of the stored and incoming `best_single_score` / `fastest_win_seconds`.
///
/// Uses SQLite `MIN`/`MAX` in the UPDATE so the database never regresses
/// a score even if the client sends stale data.
/// The opt-in check and the update are performed atomically in a single
/// conditional UPDATE (WHERE EXISTS subquery) to avoid a TOCTOU race where
/// the user opts out between the check and the write.
async fn update_leaderboard_if_opted_in(
pool: &SqlitePool,
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
// Only update if the user has opted in (leaderboard row exists).
let opted_in: Option<i64> = sqlx::query_scalar!(
let opted_in = sqlx::query!(
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
user_id
)
.fetch_optional(pool)
.await?;
.await?
.map(|r| r.leaderboard_opt_in)
.unwrap_or(0);
if opted_in != Some(1) {
if opted_in != 1 {
return Ok(());
}
let best_score = payload.stats.best_single_score as i64;
let fastest = if payload.stats.fastest_win_seconds == u64::MAX {
// Sentinel "never won" value — don't store.
None::<i64>
} else {
Some(payload.stats.fastest_win_seconds as i64)
@@ -211,7 +218,9 @@ async fn update_leaderboard_if_opted_in(
recorded_at = ?
WHERE user_id = ?"#,
best_score,
fastest, fastest, fastest,
fastest,
fastest,
fastest,
now,
user_id
)
@@ -329,9 +338,18 @@ mod tests {
assert_eq!(merged.stats.games_played, 42, "idempotent: games_played");
assert_eq!(merged.stats.games_won, 20, "idempotent: games_won");
assert_eq!(merged.stats.best_single_score, 5_500, "idempotent: best_single_score");
assert_eq!(merged.stats.fastest_win_seconds, 90, "idempotent: fastest_win_seconds");
assert_eq!(merged.stats.lifetime_score, 110_000, "idempotent: lifetime_score");
assert_eq!(
merged.stats.best_single_score, 5_500,
"idempotent: best_single_score"
);
assert_eq!(
merged.stats.fastest_win_seconds, 90,
"idempotent: fastest_win_seconds"
);
assert_eq!(
merged.stats.lifetime_score, 110_000,
"idempotent: lifetime_score"
);
assert_eq!(merged.progress.total_xp, 3_000, "idempotent: total_xp");
}
}