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]