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
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:
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 (1–86400)",
|
||||
h.time_seconds
|
||||
)));
|
||||
}
|
||||
if h.final_score < 0 || h.final_score > 1_000_000 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"final_score {} out of range (0–1000000)",
|
||||
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()))?
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user