Ability to unbind nesica card

This commit is contained in:
Ethan O'Brien
2026-09-02 10:09:24 -05:00
parent b92acb7a00
commit 59193d92f4
6 changed files with 352 additions and 304 deletions
+29
View File
@@ -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 // 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: // 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 // 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<String> {
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::<usize, String>(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) { pub fn open_card_session(card_id: &str, machine_id: &str, until: i64) {
DATABASE.lock_and_exec( DATABASE.lock_and_exec(
"UPDATE cards SET last_machine_id=?1, last_session=?2, session_until=?3 WHERE card_id=?4", "UPDATE cards SET last_machine_id=?1, last_session=?2, session_until=?3 WHERE card_id=?4",
+2
View File
@@ -226,6 +226,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/revokePermission" => webui::revoke_permission(req, body), "/api/webui/revokePermission" => webui::revoke_permission(req, body),
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body), "/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
"/api/webui/bindArcadeCard" => webui::bind_arcade_card(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 _ => api_req(req, body).await
} }
} else { } else {
@@ -246,6 +247,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req), "/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req),
"/api/webui/myScopes" => webui::my_scopes(req), "/api/webui/myScopes" => webui::my_scopes(req),
"/api/webui/listArcadeMachines" => webui::list_arcade_machines(req), "/api/webui/listArcadeMachines" => webui::list_arcade_machines(req),
"/api/webui/listArcadeCards" => webui::list_arcade_cards(req),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
} }
+217 -285
View File
@@ -7,7 +7,11 @@
// user id but re-drawing everything a player could have left on it - its login // 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 // 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 // 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 // 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 // consume_lp = false - the seam /multi_live/end already uses - and its use_lp
@@ -27,7 +31,7 @@
use jzon::{object, JsonValue}; use jzon::{object, JsonValue};
use actix_web::{web, Responder}; 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; use crate::database::arcade as database;
// The name a cabinet falls back to when its operator sent nothing usable // 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("/register", web::post().to(register))
.route("/session", web::post().to(session)) .route("/session", web::post().to(session))
.route("/bind", web::post().to(bind)) .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, // 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. // because a mangled id would silently name a different card's account.
fn card_id(body: &JsonValue) -> Option<String> { fn card_id(body: &JsonValue) -> Option<String> {
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<String> {
let card_id = card_id.trim().to_string();
if card_id.is_empty() { if card_id.is_empty() {
return None; 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()); 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 -------------------------------------------------------------- // -- endpoints --------------------------------------------------------------
// The client asks this before it offers to convert a device: a server without // 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 // 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. // 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 // A card the server cannot resolve is answered `unlinked: true` with no
// places: the account a brand new card is issued, and a rename of that same // identity at all. Nothing is created for it: an account is made on a phone and
// account while the credit that created it is still running (see // a card is linked to it from there (bind_card_to), so the cabinet shows the
// issued_by_this_credit). A guest credit ignores it - a guest is named after its // player where to do that and asks again without the card, as a guest credit.
// cabinet - and so does an ordinary card session.
async fn session(Body(body): Body) -> impl Responder { async fn session(Body(body): Body) -> impl Responder {
if disabled() { if disabled() {
return Api(None); return Api(None);
@@ -225,17 +196,16 @@ async fn session(Body(body): Body) -> impl Responder {
database::touch_machine(&machine_id); database::touch_machine(&machine_id);
let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string(); 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 // A guest credit is a session with no card_id at all or an empty one.
// an empty one. Anything else is a card being presented, and a card id that // Anything else is a card being presented, and a card id that does not
// does not parse is refused rather than quietly played as a guest on // parse is refused rather than quietly played as a guest on somebody's
// somebody's credit. // credit.
let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty(); let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty();
let card = card_id(&body); let card = card_id(&body);
if presented && card.is_none() { if presented && card.is_none() {
println!("arcade: machine {} presented an unusable card id", machine_id); println!("arcade: machine {} presented an unusable card id", machine_id);
return Api(None); return Api(None);
} }
let typed_name = body["name"].as_str().unwrap_or("").trim().to_string();
let Some(card) = card else { let Some(card) = card else {
// The cabinet's own guest, rewritten from scratch. Same user id, so the // 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, "user_id": guest_user_id,
"uuid": uuid, "uuid": uuid,
"name": machine_name, "name": machine_name,
"is_new": true "unlinked": false
})); }));
}; };
// A known card plays its own account, with every clear it has ever earned // A known card plays its own account, with every clear it has ever earned
if let Some(user_id) = database::card_user(&card) { let Some((user_id, uuid)) = resolve_card(&card) else {
let uuid = userdata::get_login_token(user_id); println!("arcade: machine {} presented a card that is linked to no account", machine_id);
if !uuid.is_empty() { return Api(Some(object!{ "unlinked": true }));
// 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);
}; };
database::set_card(&card, user_id);
open_card_session(&card, &machine_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!{ Api(Some(object!{
"user_id": user_id, "user_id": user_id,
"uuid": uuid, "uuid": uuid,
"name": name, "name": name,
"is_new": true "unlinked": false
})) }))
} }
// Point a card at a player account the caller has already identified. This is // 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 // 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 // identities are refused, the mapping is replaced - and the proof of *whose*
// the card was carrying is cleaned up - and the proof of *whose* account it is // account it is belongs to the caller: the cabinet's /api/arcade/bind takes the
// belongs to the caller: the cabinet's /api/arcade/bind takes the game's // game's data-transfer code and password (bind_card), the webui account page
// data-transfer code and password (bind_card), the webui account page takes // and the game's own take-over screen take the signed-in session itself, which
// the signed-in session itself, which already proves the account. // already proves the account.
pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> { pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> {
if disabled() { if disabled() {
return Err(String::from("Arcade mode is disabled on this server")); 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<i64, String> {
return Err(String::from("That account belongs to an arcade cabinet")); return Err(String::from("That account belongs to an arcade cabinet"));
} }
let previous = database::card_user(&card);
database::set_card(&card, user_id); 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); println!("arcade: card {} now plays as account {}", card, user_id);
Ok(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 // -- the phone's own cards --------------------------------------------------
// 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: // The signed-in player's cards. `Session` already rejected a bad token, so a
// * no other card still points at it, // uid of 0 cannot happen here; it is refused all the same rather than listing
// * it is not a cabinet's own machine or guest identity, // nobody's cards.
// * it has never registered a transfer password, so no phone ever owned it, async fn card_list(Session { key, .. }: Session) -> impl Responder {
// * and it has no live record at all - nothing was ever played on it. if disabled() {
fn orphan_of_a_rebound_card(user_id: i64) -> bool { return Api(None);
if database::account_has_card(user_id) {
return false;
} }
if database::machine_of_account(user_id).is_some() { let user_id = userdata::uid_from_login_token(&key);
return false; if user_id == 0 {
return Api(None);
} }
if userdata::has_transfer_password(user_id) { Api(Some(object!{
return false; "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); let user_id = userdata::uid_from_login_token(&key);
if user["error"].as_bool().unwrap_or(false) { if user_id == 0 {
return false; 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 ------------------------------------------------------------------ // -- lives ------------------------------------------------------------------
@@ -666,13 +638,6 @@ mod tests {
assert!(card_id(&object!{ card_id: "x".repeat(MAX_CARD_ID_LEN + 1) }).is_none()); 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 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, // the live's own masterdata thresholds (live.csv 1100101: C 20000, B 100000,
// A 250000, S 350000) // A 250000, S 350000)
@@ -689,53 +654,107 @@ mod tests {
assert_eq!(score_rank(10_000, 9_999_999), 0); assert_eq!(score_rank(10_000, 9_999_999), 0);
} }
// The card-rebind cleanup only ever takes an account that is provably a // A card resolves to the account it names, and to nothing at all once that
// throwaway: a real player's account survives losing its card // account is gone - and the dead mapping does not survive the answer
#[test] #[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(); 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 // Nobody linked it: unlinked, and nothing was created for it
let (orphan, _) = userdata::starter::create("2345").unwrap(); assert!(resolve_card("7020392000000001").is_none());
assert!(orphan_of_a_rebound_card(orphan)); assert!(db::card_user("7020392000000001").is_none());
// Somebody played on it // Linked: the account and its token
let (played, played_token) = userdata::starter::create("2346").unwrap(); let (player, token) = userdata::starter::create("Linked").unwrap();
let mut user = userdata::get_acc_from_uid(played); db::set_card("7020392000000002", player);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap(); assert_eq!(resolve_card("7020392000000002"), Some((player, token)));
userdata::save_acc(&played_token, user);
assert!(!orphan_of_a_rebound_card(played));
// Somebody can take it over from a phone // The account was deleted: unlinked from now on, mapping gone
let (phone, _) = userdata::starter::create("2347").unwrap(); userdata::delete_account(player);
userdata::user::migration::save_acc_transfer(phone, "hunter2"); assert!(resolve_card("7020392000000002").is_none());
assert!(!orphan_of_a_rebound_card(phone)); assert!(db::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived");
// 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);
} }
// bind_card_to is the rule under both entrances - the cabinet's transfer // A player's cards are theirs to list and unlink, and nobody else's
// 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] #[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(); let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db; use crate::database::arcade as db;
@@ -755,25 +774,18 @@ mod tests {
assert!(bind_card_to(card, guest_account).is_err()); assert!(bind_card_to(card, guest_account).is_err());
assert!(db::card_user(card).is_none()); assert!(db::card_user(card).is_none());
// A throwaway the card was carrying leaves with it... // A card another player linked is re-pointed, and that player's account
let (orphan, _) = userdata::starter::create("2424").unwrap(); // is untouched - the card was the only thing that changed hands
db::set_card(card, orphan); let (previous, _) = userdata::starter::create("Previous").unwrap();
db::set_card(card, previous);
assert_eq!(bind_card_to(card, player), Ok(player)); assert_eq!(bind_card_to(card, player), Ok(player));
assert_eq!(db::card_user(card), Some(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"); assert!(!userdata::get_acc_from_uid(previous)["error"].as_bool().unwrap_or(false), "re-pointing a card touched the previous account");
// ...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); 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 // 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); 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);
}
}
} }
+56 -18
View File
@@ -9,39 +9,77 @@ use rand::RngExt;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use base64::{Engine as _, engine::general_purpose}; 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 { fn generate_token() -> String {
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::rng(); let mut rng = rand::rng();
let random_string: String = (0..16) loop {
.map(|_| { let random_string: String = (0..16)
let idx = rng.random_range(0..charset.len()); .map(|_| {
charset.chars().nth(idx).unwrap() let idx = rng.random_range(0..charset.len());
}) charset.chars().nth(idx).unwrap()
.collect(); })
random_string .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 { pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
let database = userdata::get_userdata_database(); let database = userdata::get_userdata_database();
let data = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)); let uid: i64 = if let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)) {
if data.is_err() { if !verify_password(password, &hash) {
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() {
return object!{success: false}; 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<i64> {
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 { pub fn transfer_code_exists(token: &str) -> bool {
let database = userdata::get_userdata_database(); let database = userdata::get_userdata_database();
database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)).is_ok() 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 { pub fn save_acc_transfer(uid: i64, password: &str) -> String {
+47
View File
@@ -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 { pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req); let token = get_login_token(&req);
if token.is_none() { if token.is_none() {
+1 -1
Submodule webui updated: bea8024e80...823a326c6e