From f881a73dbd5ac39cfbb7cbe7359b7552f47b71cb Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Wed, 26 Aug 2026 15:19:10 -0500 Subject: [PATCH] Fix some things with the new feature --- src/router/arcade.rs | 97 +++++++++++++++++++++++++++++++++++--------- src/router/webui.rs | 22 +++++----- src/runtime.rs | 9 ++++ 3 files changed, 97 insertions(+), 31 deletions(-) diff --git a/src/router/arcade.rs b/src/router/arcade.rs index 643d605..488fad0 100644 --- a/src/router/arcade.rs +++ b/src/router/arcade.rs @@ -302,34 +302,27 @@ async fn session(Body(body): Body) -> impl Responder { })) } -// Point a card at an existing (phone) account. The proof is the data-transfer -// code and its password - the same check /api/user/gglverifymigrationcode makes -// (user.rs:214-220), through the one function that owns that comparison. -// -// Shared by the cabinet's own /api/arcade/bind and the webui account page's -// form: the browser cannot speak the game protocol (encrypted bodies behind the -// asset gate), so it gets its own entrance, not its own copy of the rule. -pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result { +// Point a card at a player account the caller has already identified. This is +// the rule every entrance shares - the card id is validated, a cabinet's own +// identities are refused, the mapping is replaced and the throwaway account +// the card was carrying is cleaned up - and the proof of *whose* account it is +// belongs to the caller: the cabinet's /api/arcade/bind takes the game's +// data-transfer code and password (bind_card), the webui account page takes +// the signed-in session itself, which already proves the account. +pub fn bind_card_to(card: &str, user_id: i64) -> Result { if disabled() { return Err(String::from("Arcade mode is disabled on this server")); } let Some(card) = card_id(&object!{ "card_id": card }) else { return Err(String::from("That is not a usable card id")); }; - - let account = userdata::user::migration::get_acc_transfer(migration_code, pass); - if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 { - return Err(String::from("Transfer code and password don't match")); - } - let Some(user_id) = account["user_id"].as_i64() else { - return Err(String::from("Transfer code and password don't match")); - }; // A cabinet's own two identities are not player accounts and may never be // behind a card. The guest in particular is rewritten for a stranger every // credit: a card pointing at it would outlive that reset, and /session hands - // out the account's current login token to whoever presents the card. The - // proof this bind takes - a transfer code and password - is exactly what a - // hostile client can register on a guest during its own credit. + // out the account's current login token to whoever presents the card. Every + // proof a bind can take - a transfer code and password, a webui login - is + // exactly what a hostile client can register on a guest during its own + // credit, so the refusal lives here, below all of them. if database::machine_of_account(user_id).is_some() { return Err(String::from("That account belongs to an arcade cabinet")); } @@ -351,6 +344,27 @@ pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result Result { + if disabled() { + return Err(String::from("Arcade mode is disabled on this server")); + } + let account = userdata::user::migration::get_acc_transfer(migration_code, pass); + if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 { + return Err(String::from("Transfer code and password don't match")); + } + let Some(user_id) = account["user_id"].as_i64() else { + return Err(String::from("Transfer code and password don't match")); + }; + bind_card_to(card, user_id) +} + async fn bind(Body(body): Body) -> impl Responder { match bind_card( body["card_id"].as_str().unwrap_or(""), @@ -717,6 +731,51 @@ mod tests { crate::database::arcade::delete_machine(&machine_id); } + // bind_card_to is the rule under both entrances - the cabinet's transfer + // proof and the webui's session - so whatever named the account, a cabinet's + // own identities are refused, and a provable throwaway goes with its card + #[test] + fn bind_card_to_refuses_cabinet_identities_and_takes_the_throwaway_with_the_card() { + let _lock = crate::runtime::lock_test_data_path(); + use crate::database::arcade as db; + + let (machine_account, _) = userdata::starter::create("Cabinet Bind").unwrap(); + let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap(); + let machine_id = db::generate_machine_id(); + db::insert_machine(&machine_id, "Cabinet Bind", machine_account, guest_account); + let (player, _) = userdata::starter::create("Player").unwrap(); + + // The id is validated, not repaired + assert!(bind_card_to("0123-4567", player).is_err()); + assert!(db::card_user("0123-4567").is_none()); + + // Neither cabinet identity may sit behind a card + let card = "4242424242424242"; + assert!(bind_card_to(card, machine_account).is_err()); + assert!(bind_card_to(card, guest_account).is_err()); + assert!(db::card_user(card).is_none()); + + // A throwaway the card was carrying leaves with it... + let (orphan, _) = userdata::starter::create("2424").unwrap(); + db::set_card(card, orphan); + assert_eq!(bind_card_to(card, player), Ok(player)); + assert_eq!(db::card_user(card), Some(player)); + assert!(userdata::get_acc_from_uid(orphan)["error"].as_bool().unwrap_or(false), "the throwaway survived"); + + // ...but an account somebody played on stays + let (played, played_token) = userdata::starter::create("2425").unwrap(); + let mut user = userdata::get_acc_from_uid(played); + user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap(); + userdata::save_acc(&played_token, user); + let other = "2525252525252525"; + db::set_card(other, played); + assert_eq!(bind_card_to(other, player), Ok(player)); + assert_eq!(db::card_user(other), Some(player)); + assert!(!userdata::get_acc_from_uid(played)["error"].as_bool().unwrap_or(false), "a played-on account followed its card"); + + db::delete_machine(&machine_id); + } + // A cabinet's song that ended with an empty life gauge is reported at // /live/retire, which carries no arcade flag - the start record is what // proves it was a cabinet's - and it lands in the ledger as a failed song diff --git a/src/router/webui.rs b/src/router/webui.rs index 6365017..c94539d 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -607,24 +607,22 @@ pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse { .body(jzon::stringify(resp)) } -// The account page's "bind arcade card" form. The cabinet's own endpoint speaks -// the encrypted game protocol behind the asset gate, which a browser cannot, so -// this is the browser's door onto the same rule - arcade::bind_card, not a copy -// of it. A webui session is required on top of the transfer code and password: -// the page is behind login anyway, and the extra proof costs nothing. +// The account page's "bind arcade card" form: the card id, and nothing else. +// The webui session already proves whose account this is, so the card is bound +// to the signed-in account through arcade::bind_card_to - the same rule the +// cabinet's /api/arcade/bind applies once its own proof, the transfer code and +// password, has named the account. The cabinet endpoint speaks the encrypted +// game protocol behind the asset gate, which a browser cannot, so this is the +// browser's door onto that rule rather than a copy of it. pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { if crate::router::arcade::disabled() { return HttpResponse::NotFound().finish(); } - if session_uid(&req).is_none() { + let Some(uid) = session_uid(&req) else { return error("Not logged in"); - } + }; let body = jzon::parse(&body).unwrap_or(object!{}); - match crate::router::arcade::bind_card( - body["card_id"].as_str().unwrap_or(""), - body["migrationCode"].as_str().unwrap_or(""), - body["pass"].as_str().unwrap_or("") - ) { + match crate::router::arcade::bind_card_to(body["card_id"].as_str().unwrap_or(""), uid) { Ok(user_id) => { let resp = object!{ result: "OK", diff --git a/src/runtime.rs b/src/runtime.rs index 34f21cc..3e40f9f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -26,6 +26,7 @@ pub struct HostConfig { pub enable_custom_songs: bool, pub enable_custom_cards: bool, pub enable_custom_3dmv: bool, + pub enable_arcade: bool, } // Lets an embedding app (or the tests) enable the opt-in custom songs feature @@ -42,6 +43,10 @@ pub fn set_enable_custom_3dmv(enabled: bool) { HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled; } +pub fn set_enable_arcade(enabled: bool) { + HOST_CONFIG.write().unwrap().enable_arcade = enabled; +} + // The --owner uids: the permission system's bootstrap grantors. Process-level // state rather than db rows so they work on a fresh install and can't be // revoked through the webui @@ -178,6 +183,9 @@ pub fn overlay_args(args: &mut crate::options::Args) { if cfg.enable_custom_3dmv { args.enable_custom_3dmv = true; } + if cfg.enable_arcade { + args.enable_arcade = true; + } } // idk why an ai put tests here but they are here now. Yay tests???? @@ -201,5 +209,6 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> { set_enable_custom_songs(true); set_enable_custom_cards(true); set_enable_custom_3dmv(true); + set_enable_arcade(true); guard }