Files
OpenFUT-Core/src/routes/auth.rs
T
funman300 bae0a2bdaa
CI / Build, lint & test (push) Successful in 3m15s
fix(matches): close the second, unguarded match-economy authority
`POST /matches/result` granted coins, XP, level-ups, statistics, four objective
metrics, loan expiry, season progression and achievements across a dozen
SEPARATE writes with no transaction and no idempotency key. Every call
re-credited the same match, and any mid-way failure half-applied it. It sat
beside `/matches/complete`, so nothing stopped one match being paid twice
through two different doors.

It cannot be made exactly-once in place: that needs a caller-supplied match
identity, and this request shape has none. Deriving one from the body would
collapse two legitimate matches with the same scoreline into one — the
under-credit trap already documented for the `fp:` fallback. So the route fails
closed: it rejects with a message naming `/matches/complete`, rather than 404,
so a caller learns why.

The behaviour it uniquely drove is kept, not deleted. `process_match` was the
ONLY caller of loan expiry and Core's season model, so both move into
`complete_match`'s transaction behind opt-in `expire_loans` / `advance_season`
flags. Both default OFF, which keeps the FIFA 17 retail path byte-identical:
FIFA 17 has its own loan and Seasons models, and Core's season END GRANTS coins
and a pack — invisible economy on a path that never asked for it. Their pooled
implementations are replaced by `expire_loans_tx` and
`season::record_match_tx`, so a loan that expires or a season that ends commits
with the match that caused it.

Notifications (level-up / objective / loan / season) were pooled side effects of
the removed path. They now emit from the route AFTER the commit — never inside
the transaction, since a failed notification must not roll back a completed
match — and only when `applied`, so a replay no longer re-notifies. The pooled
path had no replay concept and notified every time.

Also fixes a real bug this surfaced: `/auth/reset` never deleted
`match_completions`, which carries un-cascaded foreign keys to BOTH `matches`
and `profiles`. Any profile that completed a match through the authoritative
route — i.e. every FIFA 17 profile after a retail match — failed to reset with a
database error. It is now deleted first, and ordering is documented.

Tests: the 20 integration call sites move to the authoritative route through one
helper that mints a per-call identity (each call IS a distinct match). New
coverage for the closed path: it rejects without moving the balance or writing
history; Core progression stays off unless opted into; a replay does not
duplicate notifications; and a profile that completed matches can still be
reset.
2026-08-21 04:47:57 +00:00

147 lines
4.9 KiB
Rust

use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::{
app::AppState,
error::{AppError, AppResult},
extractors::GameId,
models::{club::Club, profile::CreateProfileRequest},
seed,
services::{club as club_svc, profile as profile_svc},
};
pub async fn post_auth_local(
State(state): State<AppState>,
game: GameId,
Json(req): Json<CreateProfileRequest>,
) -> AppResult<Json<Value>> {
let username = req.username.unwrap_or_else(|| "Player 1".into());
let profile = profile_svc::create_profile(&state.pool, &username, game.as_str()).await?;
let club = Club::new(&profile.id, "OpenFUT FC", 5000);
club_svc::create_club(&state.pool, &club).await?;
seed::grant_starter_pack(&state.pool, &club.id, &state.pack_defs).await?;
Ok(Json(json!({
"profile": profile,
"club": club,
"message": "Welcome to OpenFUT FC! Your club has been created."
})))
}
/// GET /auth/status — lightweight check: does a profile exist?
/// Returns 200 `{ "has_profile": true/false }` without erroring.
pub async fn get_auth_status(
State(state): State<AppState>,
game: GameId,
) -> AppResult<Json<Value>> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM profiles WHERE game_id = ?")
.bind(game.as_str())
.fetch_one(&state.pool)
.await?;
Ok(Json(json!({ "has_profile": count > 0 })))
}
#[derive(Deserialize)]
pub struct ResetRequest {
pub confirm: Option<String>,
}
/// POST /auth/reset — wipe all game data and start fresh.
/// Requires `{"confirm":"reset"}` in the request body as a safeguard against
/// accidental or unauthenticated calls. Deletes every user-data table in
/// dependency order; schema (migrations) is preserved.
pub async fn post_auth_reset(
State(state): State<AppState>,
game: GameId,
Json(req): Json<ResetRequest>,
) -> AppResult<Json<Value>> {
if req.confirm.as_deref() != Some("reset") {
return Err(AppError::BadRequest(
r#"include {"confirm":"reset"} in the request body to confirm data wipe"#.into(),
));
}
// Multi-game: reset ONLY this game's profile subtree so a FIFA 17 reset never
// wipes FIFA 23 (and vice versa). Resolve the game's profile + its clubs, then
// delete their dependent rows in reverse-dependency order.
let profile_id: Option<String> = sqlx::query_scalar(
"SELECT id FROM profiles WHERE game_id = ? ORDER BY created_at ASC LIMIT 1",
)
.bind(game.as_str())
.fetch_optional(&state.pool)
.await?;
let Some(profile_id) = profile_id else {
return Ok(Json(json!({
"reset": true,
"message": "nothing to reset for this game"
})));
};
let club_ids: Vec<String> = sqlx::query_scalar("SELECT id FROM clubs WHERE profile_id = ?")
.bind(&profile_id)
.fetch_all(&state.pool)
.await?;
for club_id in &club_ids {
sqlx::query(
"DELETE FROM squad_players WHERE squad_id IN (SELECT id FROM squads WHERE club_id = ?)",
)
.bind(club_id)
.execute(&state.pool)
.await?;
for table in ["squads", "owned_cards", "packs", "market_history"] {
sqlx::query(&format!("DELETE FROM {table} WHERE club_id = ?"))
.bind(club_id)
.execute(&state.pool)
.await?;
}
}
// Order matters: `match_completions` carries un-cascaded foreign keys to BOTH
// `matches` and `profiles`, so it has to go before either of them or the
// reset fails with a constraint error. Any profile that completed a match
// through /matches/complete has rows here.
for table in [
"match_completions",
"fut_champs_sessions",
"sbc_submissions",
"objective_progress",
"position_goals",
"statistics",
"seasons",
"season_history",
"matches",
"draft_sessions",
"daily_checkins",
] {
sqlx::query(&format!("DELETE FROM {table} WHERE profile_id = ?"))
.bind(&profile_id)
.execute(&state.pool)
.await?;
}
sqlx::query("DELETE FROM clubs WHERE profile_id = ?")
.bind(&profile_id)
.execute(&state.pool)
.await?;
sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(&profile_id)
.execute(&state.pool)
.await?;
// NOTE (follow-up): settings (global key/value), events (shared content),
// market_listings (keyed by seller_name, includes the shared NPC market),
// notifications and player_achievements are not yet game-scoped and are left
// intact. They need a game/profile key before a per-game reset can cover them.
tracing::info!(game = %game.as_str(), "Per-game reset performed");
Ok(Json(json!({
"reset": true,
"message": "All progress for this game has been wiped. Call POST /auth/local to start a new club."
})))
}