diff --git a/src/database/arcade.rs b/src/database/arcade.rs index 09a0f45..e276277 100644 --- a/src/database/arcade.rs +++ b/src/database/arcade.rs @@ -237,6 +237,35 @@ pub fn set_card(card_id: &str, user_id: i64) { // The cabinet this card is playing at right now and how long the credit it just // paid buys LP-free lives for. Written by /api/arcade/session, and only there: // the session is the one moment the server knows a credit was taken +// Every card that names this account, oldest first. The account page and the +// game's own link dialog list them; a player usually has one. +pub fn cards_of_account(user_id: i64) -> Vec { + let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else { return Vec::new(); }; + let Ok(mut stmt) = conn.prepare("SELECT card_id FROM cards WHERE user_id=?1 ORDER BY created ASC") else { return Vec::new(); }; + let Ok(rows) = stmt.query_map(params!(user_id), |row| row.get::(0)) else { return Vec::new(); }; + rows.flatten().collect() +} + +// Unlink a card from the account that owns it. False when the card is not this +// account's - a player can only ever unlink their own, and the answer says so +// rather than silently succeeding on somebody else's row. +pub fn remove_card_of(card_id: &str, user_id: i64) -> bool { + match card_user(card_id) { + Some(owner) if owner == user_id => { + remove_card(card_id); + true + } + _ => false + } +} + +// Forget a card: the mapping row goes, the account it named is not touched. +// Used when the account behind a card no longer exists (router/arcade.rs +// resolve_card) and when a player unlinks a card of their own. +pub fn remove_card(card_id: &str) { + DATABASE.lock_and_exec("DELETE FROM cards WHERE card_id=?1", params!(card_id)); +} + pub fn open_card_session(card_id: &str, machine_id: &str, until: i64) { DATABASE.lock_and_exec( "UPDATE cards SET last_machine_id=?1, last_session=?2, session_until=?3 WHERE card_id=?4", diff --git a/src/router.rs b/src/router.rs index b17c736..6ed1171 100644 --- a/src/router.rs +++ b/src/router.rs @@ -226,6 +226,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/revokePermission" => webui::revoke_permission(req, body), "/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body), "/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body), + "/api/webui/unbindArcadeCard" => webui::unbind_arcade_card(req, body), _ => api_req(req, body).await } } else { @@ -246,6 +247,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req), "/api/webui/myScopes" => webui::my_scopes(req), "/api/webui/listArcadeMachines" => webui::list_arcade_machines(req), + "/api/webui/listArcadeCards" => webui::list_arcade_cards(req), _ => api_req(req, body).await } } diff --git a/src/router/arcade.rs b/src/router/arcade.rs index 488fad0..5f537fd 100644 --- a/src/router/arcade.rs +++ b/src/router/arcade.rs @@ -7,7 +7,11 @@ // user id but re-drawing everything a player could have left on it - its login // token included - so the machine plays a clean account and the server never // accumulates dead users. With a card, the card names the account and the play -// is real progress on it. +// is real progress on it. A card names an account only because a player linked +// it - from the game's take-over screen, the webui account page, or a cabinet's +// Test Mode with a transfer code - never because it was tapped: a cabinet has no +// keyboard, so an unknown card is answered `unlinked` and the cabinet tells the +// player where to link it and sells the credit as a guest play instead. // // Credits replace LP: a live flagged `arcade` runs through live_end_ex with // consume_lp = false - the seam /multi_live/end already uses - and its use_lp @@ -27,7 +31,7 @@ use jzon::{object, JsonValue}; use actix_web::{web, Responder}; -use crate::router::{databases, global, live, multi_live, userdata, Api, Body}; +use crate::router::{databases, global, live, multi_live, userdata, Api, Body, Session}; use crate::database::arcade as database; // The name a cabinet falls back to when its operator sent nothing usable @@ -48,6 +52,12 @@ pub fn routes(cfg: &mut web::ServiceConfig) { .route("/register", web::post().to(register)) .route("/session", web::post().to(session)) .route("/bind", web::post().to(bind)) + // The phone's own card management (Docs/arcade-nesica-nfc-design.md ยง5): the + // player is signed in, so the game session is the proof of whose account it is - + // the webui account page's rule, on the game's wire. + .route("/card/list", web::post().to(card_list)) + .route("/card/link", web::post().to(card_link)) + .route("/card/unlink", web::post().to(card_unlink)) ); } @@ -83,7 +93,13 @@ fn flag(value: &JsonValue) -> bool { // A card id is an identifier, never text: it is refused rather than sanitised, // because a mangled id would silently name a different card's account. fn card_id(body: &JsonValue) -> Option { - let card_id = body["card_id"].as_str().unwrap_or("").trim().to_string(); + valid_card_id(body["card_id"].as_str().unwrap_or("")) +} + +// The same rule on a bare string, for the one caller outside this module that +// meets a card id where a transfer code is expected (userdata migration). +pub fn valid_card_id(card_id: &str) -> Option { + let card_id = card_id.trim().to_string(); if card_id.is_empty() { return None; } @@ -100,66 +116,6 @@ fn open_card_session(card: &str, machine_id: &str) { database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl()); } -// An account made for a card nobody has named yet takes the card's last four -// digits, the way a cabinet prints them on a receipt. The on-cabinet name entry -// overwrites it. -fn card_account_name(card_id: &str) -> String { - let start = card_id.len().saturating_sub(4); - card_id[start..].to_string() -} - -// The account this card was issued seconds ago, and which nothing has happened -// to since - the only account /api/arcade/session will ever rename. -// -// The cabinet's name entry is a second /session for the same card: the first one -// created the account and answered is_new, the player typed a name, and this is -// the same call again carrying it. By then the card is KNOWN, so the rename has -// to be told apart from an ordinary card session, and it has to be told apart -// hard: a rename that reached a real account would let anyone holding a card id -// retitle a stranger's save. Every clause below has to hold. -// -// Deliberately not a `renamed` column: the account itself carries the proof. -fn issued_by_this_credit(card: &str, user_id: i64, now: i64, ttl: i64) -> bool { - // The credit that created it is still running, at this card. live_card_session - // is the window /api/arcade/session opened and nothing else ever opens - // (database/arcade.rs:240), so this is "the same credit, still going". `ttl` - // is session_ttl() in production and the test's own number in the test - the - // shape purge_machines_before and retire_user_at already use here. - let Some((open_card, opened)) = database::live_card_session(user_id, now) else { - return false; - }; - if open_card != card || now - opened > ttl { - return false; - } - // A cabinet's own machine or guest identity is never a card's account - if database::machine_of_account(user_id).is_some() { - return false; - } - // A phone has owned it - the same test bind_card's cleanup trusts - if userdata::has_transfer_password(user_id) { - return false; - } - let user = userdata::get_acc_from_uid(user_id); - if user["error"].as_bool().unwrap_or(false) { - return false; - } - // It still carries the placeholder /session gave it, and nothing has been - // played on it. Either alone would be enough to make a rename harmless; both - // together make it provable. - if user["user"]["name"].as_str().unwrap_or("") != card_account_name(card) { - return false; - } - user["live_list"].is_empty() && user["live_mission_list"].is_empty() -} - -// The one write a rename is. /api/user does exactly this for a phone player -// (user.rs:138-140); there is no other name in an account. -fn rename_account(user_id: i64, login_token: &str, name: &str) { - let mut user = userdata::get_acc_from_uid(user_id); - user["user"]["name"] = name.into(); - userdata::save_acc(login_token, user); -} - // -- endpoints -------------------------------------------------------------- // The client asks this before it offers to convert a device: a server without @@ -205,14 +161,29 @@ async fn register(Body(body): Body) -> impl Responder { })) } +// The account a card names, if it names one that still exists. None is the +// `unlinked` answer: a card nobody has linked, or one whose account was deleted +// (through the webui, or with the cabinet that owned it) - that mapping is +// dropped on the spot so the card is simply unlinked from then on, rather than +// pointing at nothing forever. +fn resolve_card(card: &str) -> Option<(i64, String)> { + let user_id = database::card_user(card)?; + let uuid = userdata::get_login_token(user_id); + if uuid.is_empty() { + println!("arcade: card mapping pointed at missing account {} - unlinking the card", user_id); + database::remove_card(card); + return None; + } + Some((user_id, uuid)) +} + // One credit. Answers the account the player is about to become: the cabinet's // guest (reset in place) or, with a card, the account that card names. // -// `name` is the cabinet's on-screen name entry, and it is read in exactly two -// places: the account a brand new card is issued, and a rename of that same -// account while the credit that created it is still running (see -// issued_by_this_credit). A guest credit ignores it - a guest is named after its -// cabinet - and so does an ordinary card session. +// A card the server cannot resolve is answered `unlinked: true` with no +// identity at all. Nothing is created for it: an account is made on a phone and +// a card is linked to it from there (bind_card_to), so the cabinet shows the +// player where to do that and asks again without the card, as a guest credit. async fn session(Body(body): Body) -> impl Responder { if disabled() { return Api(None); @@ -225,17 +196,16 @@ async fn session(Body(body): Body) -> impl Responder { database::touch_machine(&machine_id); let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string(); - // A guest credit is a session with no card_id at all (every credit in v1) or - // an empty one. Anything else is a card being presented, and a card id that - // does not parse is refused rather than quietly played as a guest on - // somebody's credit. + // A guest credit is a session with no card_id at all or an empty one. + // Anything else is a card being presented, and a card id that does not + // parse is refused rather than quietly played as a guest on somebody's + // credit. let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty(); let card = card_id(&body); if presented && card.is_none() { println!("arcade: machine {} presented an unusable card id", machine_id); return Api(None); } - let typed_name = body["name"].as_str().unwrap_or("").trim().to_string(); let Some(card) = card else { // The cabinet's own guest, rewritten from scratch. Same user id, so the @@ -253,62 +223,32 @@ async fn session(Body(body): Body) -> impl Responder { "user_id": guest_user_id, "uuid": uuid, "name": machine_name, - "is_new": true + "unlinked": false })); }; // A known card plays its own account, with every clear it has ever earned - if let Some(user_id) = database::card_user(&card) { - let uuid = userdata::get_login_token(user_id); - if !uuid.is_empty() { - // The cabinet's name entry, coming back for the account this credit - // just made. Checked BEFORE the window is re-opened, because the - // proof is the window the previous call opened. - if !typed_name.is_empty() && issued_by_this_credit(&card, user_id, global::timestamp() as i64, session_ttl()) { - let named = userdata::starter::clean_name(&typed_name, &card_account_name(&card)); - rename_account(user_id, &uuid, &named); - println!("arcade: machine {} named its new card's account {} \"{}\"", machine_id, user_id, named); - } - open_card_session(&card, &machine_id); - let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string(); - return Api(Some(object!{ - "user_id": user_id, - "uuid": uuid, - "name": name, - "is_new": false - })); - } - // The mapping outlived its account (deleted through the webui, or aged - // out with a machine). The card is not broken: it gets a new account. - println!("arcade: card mapping pointed at missing account {} - reissuing", user_id); - } - - // A card nobody has seen before. It gets an account named after its own last - // four digits unless the cabinet already has a name for it - which it only - // does when something other than the entrance's own two-step asked for one. - let name = userdata::starter::clean_name(&typed_name, &card_account_name(&card)); - let Some((user_id, uuid)) = userdata::starter::create(&name) else { - return Api(None); + let Some((user_id, uuid)) = resolve_card(&card) else { + println!("arcade: machine {} presented a card that is linked to no account", machine_id); + return Api(Some(object!{ "unlinked": true })); }; - database::set_card(&card, user_id); open_card_session(&card, &machine_id); - println!("arcade: machine {} issued account {} to a new card", machine_id, user_id); - + let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string(); Api(Some(object!{ "user_id": user_id, "uuid": uuid, "name": name, - "is_new": true + "unlinked": false })) } // 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. +// identities are refused, the mapping is replaced - 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 +// and the game's own take-over screen take 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")); @@ -327,19 +267,7 @@ pub fn bind_card_to(card: &str, user_id: i64) -> Result { return Err(String::from("That account belongs to an arcade cabinet")); } - let previous = database::card_user(&card); database::set_card(&card, user_id); - - // A card that was carrying a throwaway account the cabinet made for it takes - // that account with it. Anything the player might care about keeps the card - // from taking it: see orphan_of_a_rebound_card. - if let Some(previous) = previous - && previous != user_id - && orphan_of_a_rebound_card(previous) { - println!("arcade: card {} left empty account {} behind - removing", card, previous); - userdata::delete_account(previous); - } - println!("arcade: card {} now plays as account {}", card, user_id); Ok(user_id) } @@ -379,28 +307,72 @@ async fn bind(Body(body): Body) -> impl Responder { } } -// An account /api/arcade/session issued to an unknown card that was then never -// used for anything. Every one of these has to hold for it to be thrown away -// with the card, because a false positive deletes somebody's save: -// * no other card still points at it, -// * it is not a cabinet's own machine or guest identity, -// * it has never registered a transfer password, so no phone ever owned it, -// * and it has no live record at all - nothing was ever played on it. -fn orphan_of_a_rebound_card(user_id: i64) -> bool { - if database::account_has_card(user_id) { - return false; +// -- the phone's own cards -------------------------------------------------- + +// The signed-in player's cards. `Session` already rejected a bad token, so a +// uid of 0 cannot happen here; it is refused all the same rather than listing +// nobody's cards. +async fn card_list(Session { key, .. }: Session) -> impl Responder { + if disabled() { + return Api(None); } - if database::machine_of_account(user_id).is_some() { - return false; + let user_id = userdata::uid_from_login_token(&key); + if user_id == 0 { + return Api(None); } - if userdata::has_transfer_password(user_id) { - return false; + Api(Some(object!{ + "card_ids": database::cards_of_account(user_id) + })) +} + +// Link a card to the signed-in account: the tap on the phone's take-over issue +// screen (NesicaLinkDialog). bind_card_to is the shared rule - the id is +// validated, a cabinet identity is refused, and a card another account had +// linked is re-pointed; `rebound` tells the new owner that is what happened. +async fn card_link(Session { key, body }: Session) -> impl Responder { + if disabled() { + return Api(None); } - let user = userdata::get_acc_from_uid(user_id); - if user["error"].as_bool().unwrap_or(false) { - return false; + let user_id = userdata::uid_from_login_token(&key); + if user_id == 0 { + return Api(None); } - user["live_list"].is_empty() && user["live_mission_list"].is_empty() + let Some(card) = card_id(&body) else { + println!("arcade: account {} tried to link an unusable card id", user_id); + return Api(None); + }; + let previous = database::card_user(&card); + match bind_card_to(&card, user_id) { + Ok(_) => Api(Some(object!{ + "card_id": card, + "rebound": previous.is_some_and(|p| p != user_id) + })), + Err(reason) => { + println!("arcade: link refused for account {} - {}", user_id, reason); + Api(None) + } + } +} + +// Unlink one of the signed-in account's own cards. The one revocation a card +// has: a lost card stops naming the account the moment its owner says so. +async fn card_unlink(Session { key, body }: Session) -> impl Responder { + if disabled() { + return Api(None); + } + let user_id = userdata::uid_from_login_token(&key); + if user_id == 0 { + return Api(None); + } + let Some(card) = card_id(&body) else { + return Api(None); + }; + if !database::remove_card_of(&card, user_id) { + println!("arcade: account {} tried to unlink a card that is not its own", user_id); + return Api(None); + } + println!("arcade: account {} unlinked card {}", user_id, card); + Api(Some(object!{ "card_id": card })) } // -- lives ------------------------------------------------------------------ @@ -666,13 +638,6 @@ mod tests { assert!(card_id(&object!{ card_id: "x".repeat(MAX_CARD_ID_LEN + 1) }).is_none()); } - // The account a fresh card gets is named after the card - #[test] - fn a_new_card_is_named_after_its_last_four_digits() { - assert_eq!(card_account_name("0123456789012345"), "2345"); - assert_eq!(card_account_name("12"), "12"); - } - // The rank stored in the ledger is the one the result screen shows, read off // the live's own masterdata thresholds (live.csv 1100101: C 20000, B 100000, // A 250000, S 350000) @@ -689,53 +654,107 @@ mod tests { assert_eq!(score_rank(10_000, 9_999_999), 0); } - // The card-rebind cleanup only ever takes an account that is provably a - // throwaway: a real player's account survives losing its card + // A card resolves to the account it names, and to nothing at all once that + // account is gone - and the dead mapping does not survive the answer #[test] - fn only_an_untouched_throwaway_follows_its_card() { + fn a_card_resolves_to_its_account_and_a_dead_mapping_is_dropped() { let _lock = crate::runtime::lock_test_data_path(); + use crate::database::arcade as db; - // Never played, no card, no password, not a cabinet: a throwaway - let (orphan, _) = userdata::starter::create("2345").unwrap(); - assert!(orphan_of_a_rebound_card(orphan)); + // Nobody linked it: unlinked, and nothing was created for it + assert!(resolve_card("7020392000000001").is_none()); + assert!(db::card_user("7020392000000001").is_none()); - // Somebody played on it - let (played, played_token) = userdata::starter::create("2346").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); - assert!(!orphan_of_a_rebound_card(played)); + // Linked: the account and its token + let (player, token) = userdata::starter::create("Linked").unwrap(); + db::set_card("7020392000000002", player); + assert_eq!(resolve_card("7020392000000002"), Some((player, token))); - // Somebody can take it over from a phone - let (phone, _) = userdata::starter::create("2347").unwrap(); - userdata::user::migration::save_acc_transfer(phone, "hunter2"); - assert!(!orphan_of_a_rebound_card(phone)); - - // Another card still points at it - let (shared, _) = userdata::starter::create("2348").unwrap(); - crate::database::arcade::set_card("9999888877776666", shared); - assert!(!orphan_of_a_rebound_card(shared)); - - // It is a cabinet's own identity - let (machine_account, _) = userdata::starter::create("Cabinet").unwrap(); - let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap(); - let machine_id = crate::database::arcade::generate_machine_id(); - crate::database::arcade::insert_machine(&machine_id, "Cabinet", machine_account, guest_account); - assert!(!orphan_of_a_rebound_card(machine_account)); - assert!(!orphan_of_a_rebound_card(guest_account)); - - // An account that no longer exists is not deleted twice - userdata::delete_account(orphan); - assert!(!orphan_of_a_rebound_card(orphan)); - - crate::database::arcade::delete_machine(&machine_id); + // The account was deleted: unlinked from now on, mapping gone + userdata::delete_account(player); + assert!(resolve_card("7020392000000002").is_none()); + assert!(db::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived"); } - // 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 + // A player's cards are theirs to list and unlink, and nobody else's #[test] - fn bind_card_to_refuses_cabinet_identities_and_takes_the_throwaway_with_the_card() { + fn an_account_lists_and_unlinks_only_its_own_cards() { + let _lock = crate::runtime::lock_test_data_path(); + use crate::database::arcade as db; + + let (mine, _) = userdata::starter::create("Mine").unwrap(); + let (theirs, _) = userdata::starter::create("Theirs").unwrap(); + assert!(db::cards_of_account(mine).is_empty()); + + db::set_card("7020392000000011", mine); + db::set_card("7020392000000012", mine); + db::set_card("7020392000000013", theirs); + assert_eq!(db::cards_of_account(mine), vec!["7020392000000011".to_string(), "7020392000000012".to_string()]); + assert_eq!(db::cards_of_account(theirs), vec!["7020392000000013".to_string()]); + + // Somebody else's card is refused and stays where it was + assert!(!db::remove_card_of("7020392000000013", mine)); + assert_eq!(db::card_user("7020392000000013"), Some(theirs)); + // A card nobody linked is not "unlinked" either + assert!(!db::remove_card_of("7020392000000014", mine)); + + assert!(db::remove_card_of("7020392000000011", mine)); + assert!(db::card_user("7020392000000011").is_none()); + assert_eq!(db::cards_of_account(mine), vec!["7020392000000012".to_string()]); + + db::remove_card("7020392000000012"); + db::remove_card("7020392000000013"); + userdata::delete_account(mine); + userdata::delete_account(theirs); + } + + // A linked card stands in for the transfer code, and the password still + // stands in front of the account + #[test] + fn a_linked_card_is_a_transfer_code_with_the_password_still_required() { + let _lock = crate::runtime::lock_test_data_path(); + use crate::database::arcade as db; + use userdata::user::migration::{get_acc_transfer, transfer_code_exists}; + + let (player, token) = userdata::starter::create("Card Transfer").unwrap(); + let card = "7020392000000021"; + + // Not linked: not a code + assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap()); + assert!(!transfer_code_exists(card)); + + // Linked, but the account never registered a transfer password: still no + db::set_card(card, player); + assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap()); + assert!(!get_acc_transfer(card, "")["success"].as_bool().unwrap()); + assert!(transfer_code_exists(card), "a linked card should read as an existing code"); + + // With the password the card is the code... + userdata::user::migration::save_acc_transfer(player, "hunter2"); + let by_card = get_acc_transfer(card, "hunter2"); + assert!(by_card["success"].as_bool().unwrap()); + assert_eq!(by_card["user_id"].as_i64(), Some(player)); + assert_eq!(by_card["login_token"].as_str(), Some(token.as_str())); + // ...the wrong password is refused... + assert!(!get_acc_transfer(card, "wrong")["success"].as_bool().unwrap()); + // ...and the real code still works exactly as before + let code = userdata::user::migration::get_acc_token(player); + assert!(get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap()); + assert!(!code.chars().all(|c| c.is_ascii_digit()), "a transfer code was drawn in the card id space"); + + // Unlinked again: the card is nobody's code + db::remove_card(card); + assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap()); + + userdata::delete_account(player); + } + + // bind_card_to is the rule under every entrance - the cabinet's transfer + // proof, the webui's session, the game's own take-over screen - so whatever + // named the account, a cabinet's own identities are refused and a card that + // already names an account is re-pointed, never refused + #[test] + fn bind_card_to_refuses_cabinet_identities_and_repoints_a_linked_card() { let _lock = crate::runtime::lock_test_data_path(); use crate::database::arcade as db; @@ -755,25 +774,18 @@ mod tests { 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); + // A card another player linked is re-pointed, and that player's account + // is untouched - the card was the only thing that changed hands + let (previous, _) = userdata::starter::create("Previous").unwrap(); + db::set_card(card, previous); 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"); + assert!(!userdata::get_acc_from_uid(previous)["error"].as_bool().unwrap_or(false), "re-pointing a card touched the previous account"); db::delete_machine(&machine_id); + for id in [player, previous, machine_account, guest_account] { + userdata::delete_account(id); + } } // A cabinet's song that ended with an empty life gauge is reported at @@ -904,84 +916,4 @@ mod tests { db::delete_machine(&live_id); } - - // A card the server has never seen gets an account named after its own last - // four digits, and the name the player then types at the cabinet lands on - // that account - and on nothing else. The rename is a second /session for - // the same card, so every other account has to be immune to it. - #[test] - fn a_new_cards_account_takes_the_name_the_player_types() { - let _lock = crate::runtime::lock_test_data_path(); - use crate::database::arcade as db; - - const TTL: i64 = 30 * 60; - let now = global::timestamp() as i64; - let card = "6060606060602345"; - let machine_id = db::generate_machine_id(); - - // /api/arcade/session, first call: an unknown card, so the account is - // created under the card's last four digits and the credit's window opens - let name = card_account_name(card); - assert_eq!(name, "2345"); - let (user_id, token) = userdata::starter::create(&name).unwrap(); - db::set_card(card, user_id); - db::open_card_session(card, &machine_id, now + TTL); - - // ...and the same call again, carrying the name the player typed - assert!(issued_by_this_credit(card, user_id, now, TTL)); - rename_account(user_id, &token, "Ethan"); - assert_eq!(userdata::get_name_and_rank(user_id)["user_name"].as_str(), Some("Ethan")); - - // Once named it is somebody's account: a second name entry on the same - // card cannot touch it - assert!(!issued_by_this_credit(card, user_id, now, TTL), "a named account was still renameable"); - - // Neither can one whose credit has ended - the window is the proof - let later_card = "6060606060601111"; - let (later, _) = userdata::starter::create(&card_account_name(later_card)).unwrap(); - db::set_card(later_card, later); - db::open_card_session(later_card, &machine_id, now + TTL); - db::backdate_card_session_for_test(later_card, now - TTL - 1, now + TTL); - assert!(!issued_by_this_credit(later_card, later, now, TTL), "an old credit could still rename"); - db::backdate_card_session_for_test(later_card, now, now - 1); - assert!(!issued_by_this_credit(later_card, later, now, TTL), "a closed window could still rename"); - - // Nor a card presented at one cabinet renaming through another card's row - db::open_card_session(later_card, &machine_id, now + TTL); - assert!(!issued_by_this_credit(card, later, now, TTL), "a different card's id could rename"); - - // A phone account behind a card is never renameable, however fresh its - // window is: it has a transfer password, and its name is its own - let phone_card = "6060606060602222"; - let (phone, _) = userdata::starter::create(&card_account_name(phone_card)).unwrap(); - userdata::user::migration::save_acc_transfer(phone, "hunter2"); - db::set_card(phone_card, phone); - db::open_card_session(phone_card, &machine_id, now + TTL); - assert!(!issued_by_this_credit(phone_card, phone, now, TTL), "a phone account was renameable"); - - // And neither is an account that has already played something - let played_card = "6060606060603333"; - let (played, played_token) = userdata::starter::create(&card_account_name(played_card)).unwrap(); - db::set_card(played_card, played); - db::open_card_session(played_card, &machine_id, now + TTL); - assert!(issued_by_this_credit(played_card, played, now, TTL)); - 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); - assert!(!issued_by_this_credit(played_card, played, now, TTL), "a played account was renameable"); - - // Finally: a cabinet's own identity, which a card may not name at all - let (machine_account, _) = userdata::starter::create("Cabinet Name").unwrap(); - let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap(); - db::insert_machine(&machine_id, "Cabinet Name", machine_account, guest_account); - let cabinet_card = "6060606060604444"; - db::set_card(cabinet_card, guest_account); - db::open_card_session(cabinet_card, &machine_id, now + TTL); - assert!(!issued_by_this_credit(cabinet_card, guest_account, now, TTL), "a cabinet identity was renameable"); - - db::delete_machine(&machine_id); - for id in [user_id, later, phone, played, machine_account, guest_account] { - userdata::delete_account(id); - } - } } diff --git a/src/router/userdata/user/migration.rs b/src/router/userdata/user/migration.rs index b6b3b20..fb5c299 100644 --- a/src/router/userdata/user/migration.rs +++ b/src/router/userdata/user/migration.rs @@ -9,39 +9,77 @@ use rand::RngExt; use sha2::{Digest, Sha256}; use base64::{Engine as _, engine::general_purpose}; +// A transfer code is never sixteen digits: that shape is a NESiCA card id +// (router/arcade.rs, ArcadeCardReader.CardIdLength), which get_acc_transfer +// also accepts, so the two spaces are kept disjoint by construction rather than +// by the odds (10/36 to the sixteenth) of a draw landing on all digits. fn generate_token() -> String { let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut rng = rand::rng(); - let random_string: String = (0..16) - .map(|_| { - let idx = rng.random_range(0..charset.len()); - charset.chars().nth(idx).unwrap() - }) - .collect(); - random_string + loop { + let random_string: String = (0..16) + .map(|_| { + let idx = rng.random_range(0..charset.len()); + charset.chars().nth(idx).unwrap() + }) + .collect(); + if !random_string.chars().all(|c| c.is_ascii_digit()) { + return random_string; + } + } } +// The account behind a transfer code, or - when no code matches and the arcade +// module is on - behind a linked NESiCA card presented as the code. The card is +// a transfer code the player cannot forget: it is the id on the card, so the +// phone's take-over screen fills the ID field by tapping it (NesicaTakeOverTap) +// and the rest of the flow, on every route that reaches this function (the +// desktop /api/user/gglverifymigrationcode, the GREE emulation's +// /migration/code/verify, a cabinet's /api/arcade/bind), is untouched. The +// password is still required: the card is readable by any NFC phone within a +// few centimetres, so on its own it proves possession, not the account. pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue { let database = userdata::get_userdata_database(); - let data = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)); - if data.is_err() { - return object!{success: false}; - } - if verify_password(password, &data.unwrap()) { - let uid: i64 = database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap(); - let login_token = userdata::get_login_token(uid); - if login_token == String::new() { + let uid: i64 = if let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)) { + if !verify_password(password, &hash) { return object!{success: false}; } - return object!{success: true, login_token: login_token, user_id: uid}; + database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap() + } else { + let Some(uid) = linked_card_user(token) else { + return object!{success: false}; + }; + let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(uid)) else { + return object!{success: false}; + }; + if !verify_password(password, &hash) { + return object!{success: false}; + } + uid + }; + let login_token = userdata::get_login_token(uid); + if login_token == String::new() { + return object!{success: false}; } - object!{success: false} + object!{success: true, login_token: login_token, user_id: uid} } -// Used by gree +// The account a NESiCA card names, when the arcade module is on and the id has +// the shape the module accepts. Off, the cards table is never opened. +fn linked_card_user(card: &str) -> Option { + if crate::router::arcade::disabled() { + return None; + } + let card = crate::router::arcade::valid_card_id(card)?; + crate::database::arcade::card_user(&card) +} + +// Used by gree, to tell "wrong password" (7004) from "no such code" (7002). A +// linked card is a code here for the same reason it is one in get_acc_transfer. pub fn transfer_code_exists(token: &str) -> bool { let database = userdata::get_userdata_database(); database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)).is_ok() + || linked_card_user(token).is_some() } pub fn save_acc_transfer(uid: i64, password: &str) -> String { diff --git a/src/router/webui.rs b/src/router/webui.rs index 807188e..22a5c68 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -634,6 +634,53 @@ pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { } } +// The signed-in account's linked cards, for the account page's list. +pub fn list_arcade_cards(req: HttpRequest) -> HttpResponse { + if crate::router::arcade::disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let resp = object!{ + result: "OK", + data: { + card_ids: crate::database::arcade::cards_of_account(uid) + } + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// Unlink one of the signed-in account's own cards - the revocation a lost card +// needs, and the only way a card stops naming an account short of somebody +// else linking it. +pub fn unbind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { + if crate::router::arcade::disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + let Some(card) = crate::router::arcade::valid_card_id(body["card_id"].as_str().unwrap_or("")) else { + return error("That is not a usable card id"); + }; + if !crate::database::arcade::remove_card_of(&card, uid) { + return error("That card is not linked to this account"); + } + let resp = object!{ + result: "OK", + data: { + card_id: card + } + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse { let token = get_login_token(&req); if token.is_none() { diff --git a/webui b/webui index bea8024..823a326 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit bea8024e809654af793d4287e0e869e33669ae82 +Subproject commit 823a326c6e55bbd0ce0a058babcaba0fef95df14