From 2d9ea66ee3bb35db8fde4e1814081491bda6b78c Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Wed, 2 Sep 2026 15:06:17 -0500 Subject: [PATCH] Redo card syncing --- src/database/arcade.rs | 260 +++++++--------- src/router.rs | 8 +- src/router/arcade.rs | 416 +++----------------------- src/router/user.rs | 43 +++ src/router/userdata/user/migration.rs | 201 +++++++++++-- src/router/webui.rs | 32 +- webui | 2 +- 7 files changed, 387 insertions(+), 575 deletions(-) diff --git a/src/database/arcade.rs b/src/database/arcade.rs index e276277..7161084 100644 --- a/src/database/arcade.rs +++ b/src/database/arcade.rs @@ -15,20 +15,18 @@ lazy_static! { // rewritten from scratch at every credit. Neither is ever handed out twice and // neither accumulates, so the arcade never leaves dead users behind. // -// `cards` maps a physical card id to the account it plays as. `last_machine_id` -// / `last_session` are the "which cabinet is this card sitting at" record: a -// card account is not owned by any one machine, so /live/end attributes its play -// to the machine that most recently ran a session for that card. Keeping it as -// two columns on the row the session already writes is the whole design - no -// second table, no row to expire, and the attribution can never outlive the -// mapping it belongs to. -// -// `session_until` is the same row's other half and the one that costs money: the -// moment the credit that /api/arcade/session took stops buying LP-free lives. -// Before it, a card account plays as a cabinet does; after it, the same account -// is an ordinary phone account again. It is a stamp rather than a flag so that a -// cabinet that loses power mid-credit expires on its own with nothing to clean -// up, and so an operator can size the window with --arcade-session-ttl. +// Which account a card names lives in userdata.db (userdata::user::migration, +// the `cards` table): linking a card is a plain account feature and works with +// this module off. What lives here is only the cabinet side of a card: +// `card_sessions` is the "which cabinet is this card sitting at" record - +// `last_machine_id` / `last_session` attribute a card account's play to the +// machine that most recently ran a session for it - and `session_until` is the +// one that costs money: the moment the credit that /api/arcade/session took +// stops buying LP-free lives. Before it, a card account plays as a cabinet does; +// after it, the same account is an ordinary phone account again. It is a stamp +// rather than a flag so that a cabinet that loses power mid-credit expires on +// its own with nothing to clean up, and so an operator can size the window with +// --arcade-session-ttl. Re-linking a card clears its row (card_relinked). // // `plays` is the bookkeeping ledger: one row per arcade song, cleared or not. // `cleared` is 0 for a song whose life gauge emptied: the client plays it out and @@ -47,10 +45,8 @@ CREATE TABLE IF NOT EXISTS machines ( created BIGINT NOT NULL, last_seen BIGINT NOT NULL ); -CREATE TABLE IF NOT EXISTS cards ( +CREATE TABLE IF NOT EXISTS card_sessions ( card_id TEXT NOT NULL PRIMARY KEY, - user_id BIGINT NOT NULL, - created BIGINT NOT NULL, last_machine_id TEXT NOT NULL DEFAULT '', last_session BIGINT NOT NULL DEFAULT 0, session_until BIGINT NOT NULL DEFAULT 0 @@ -68,32 +64,35 @@ CREATE TABLE IF NOT EXISTS plays ( ); CREATE INDEX IF NOT EXISTS plays_machine ON plays (machine_id); ").unwrap(); - // Upgrade databases written before the card session record existed. Existing - // mappings simply have no cabinet attached until their next session. - if conn.prepare("SELECT last_machine_id FROM cards LIMIT 1;").is_err() { - println!("Upgrading arcade card table"); - conn.execute("ALTER TABLE cards ADD COLUMN last_machine_id TEXT NOT NULL DEFAULT '';", []).unwrap(); - conn.execute("ALTER TABLE cards ADD COLUMN last_session BIGINT NOT NULL DEFAULT 0;", []).unwrap(); - } - // Upgrade databases written before the LP-free window existed. 0 is "this - // card is not at a cabinet", so every existing mapping starts closed and - // opens at its next session - the safe direction. - if conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_err() { - println!("Upgrading arcade card table (session window)"); - conn.execute("ALTER TABLE cards ADD COLUMN session_until BIGINT NOT NULL DEFAULT 0;", []).unwrap(); - } - // Upgrade databases written before failed songs were recorded at all. Every - // row already in the ledger got there through /live/end, which is a clear. - if conn.prepare("SELECT cleared FROM plays LIMIT 1;").is_err() { - println!("Upgrading arcade play table (cleared)"); - conn.execute("ALTER TABLE plays ADD COLUMN cleared INTEGER NOT NULL DEFAULT 1;", []).unwrap(); + // Databases from before the card mapping moved to userdata.db carry a + // `cards` table here. Its mappings move over once, its windows become + // card_sessions rows, and the table goes. + if conn.prepare("SELECT user_id FROM cards LIMIT 1;").is_ok() { + println!("Moving arcade card mappings to userdata"); + let has_sessions = conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_ok(); + let query = if has_sessions { + "SELECT card_id, user_id, created, last_machine_id, last_session, session_until FROM cards" + } else { + "SELECT card_id, user_id, created, '', 0, 0 FROM cards" + }; + let mut stmt = conn.prepare(query).unwrap(); + let rows: Vec<(String, i64, i64, String, i64, i64)> = stmt.query_map([], |row| Ok(( + row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)? + ))).unwrap().flatten().collect(); + drop(stmt); + for (card_id, user_id, created, last_machine_id, last_session, session_until) in rows { + crate::router::userdata::user::migration::import_card(&card_id, user_id, created); + if !last_machine_id.is_empty() || session_until != 0 { + conn.execute( + "INSERT OR REPLACE INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)", + params!(card_id, last_machine_id, last_session, session_until) + ).unwrap(); + } + } + conn.execute("DROP TABLE cards;", []).unwrap(); } } -// 16 hex characters, the shape the client stores in ArcadeSaveData.Machine and -// presents on every later call. It is the only thing that authenticates a -// cabinet, so it is drawn at full width rather than derived from anything -// guessable, and re-drawn on the (never observed) collision pub fn generate_machine_id() -> String { const CHARSET: &[u8] = b"0123456789abcdef"; let mut rng = rand::rng(); @@ -154,7 +153,7 @@ pub fn machine_of_account(user_id: i64) -> Option { pub fn delete_machine(machine_id: &str) { DATABASE.lock_and_exec("DELETE FROM plays WHERE machine_id=?1", params!(machine_id)); - DATABASE.lock_and_exec("UPDATE cards SET last_machine_id='', last_session=0, session_until=0 WHERE last_machine_id=?1", params!(machine_id)); + DATABASE.lock_and_exec("DELETE FROM card_sessions WHERE last_machine_id=?1", params!(machine_id)); DATABASE.lock_and_exec("DELETE FROM machines WHERE machine_id=?1", params!(machine_id)); } @@ -218,70 +217,43 @@ pub fn machines_last_seen_before(cutoff: i64) -> JsonValue { rv } -pub fn card_user(card_id: &str) -> Option { - DATABASE.lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok() -} - -// Point a card at an account. `created` survives a re-bind: the card is the same -// physical object, only the account behind it changed. The cabinet record is -// cleared, because the previous holder's sessions say nothing about this one - -// and so is the LP-free window, which was bought for the previous account -pub fn set_card(card_id: &str, user_id: i64) { - DATABASE.lock_and_exec( - "INSERT INTO cards (card_id, user_id, created, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, '', 0, 0) - ON CONFLICT(card_id) DO UPDATE SET user_id=?2, last_machine_id='', last_session=0, session_until=0", - params!(card_id, user_id, global::timestamp() as 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", - params!(machine_id, global::timestamp() as i64, until, card_id) + "INSERT INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(card_id) DO UPDATE SET last_machine_id=?2, last_session=?3, session_until=?4", + params!(card_id, machine_id, global::timestamp() as i64, until) ); } -// The card of this account whose cabinet session is still open at `now`, as -// (card id, when the session started). None when no card of the account is at a -// cabinet - which is every phone account, and every card between credits. -pub fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> { +// A card that changed hands takes nothing of the previous holder's credit with it +pub fn clear_card_session(card_id: &str) { + DATABASE.lock_and_exec("DELETE FROM card_sessions WHERE card_id=?1", params!(card_id)); +} + +fn placeholders(count: usize) -> String { + (1..=count).map(|i| format!("?{}", i)).collect::>().join(", ") +} + +// Of these cards (an account's, userdata::user::migration::cards_of_account), +// the one whose cabinet session is still open at `now`, as (card id, when the +// session started). None when none of them is at a cabinet - which is every +// phone account, and every card between credits. +pub fn live_card_session(cards: &[String], now: i64) -> Option<(String, i64)> { + if cards.is_empty() { + return None; + } let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?; - let mut stmt = conn.prepare( - "SELECT card_id, last_session FROM cards WHERE user_id=?1 AND session_until>?2 ORDER BY session_until DESC LIMIT 1" - ).ok()?; - stmt.query_row(params!(user_id, now), |row| Ok((row.get::(0)?, row.get::(1)?))).ok() + let sql = format!( + "SELECT card_id, last_session FROM card_sessions WHERE card_id IN ({}) AND session_until>?{} ORDER BY session_until DESC LIMIT 1", + placeholders(cards.len()), cards.len() + 1 + ); + let mut stmt = conn.prepare(&sql).ok()?; + let mut args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect(); + args.push(&now); + stmt.query_row(args.as_slice(), |row| Ok((row.get::(0)?, row.get::(1)?))).ok() } // A live starting inside the window pushes its end back, so a credit whose songs @@ -290,7 +262,7 @@ pub fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> { // ceiling would turn one credit into an endless supply of LP-free lives pub fn extend_card_session(card_id: &str, until: i64) { DATABASE.lock_and_exec( - "UPDATE cards SET session_until=?1 WHERE card_id=?2 AND session_until Option { - let machine_id: String = DATABASE.lock_and_select_type( - "SELECT last_machine_id FROM cards WHERE user_id=?1 AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1", - params!(user_id) - ).ok()?; +// The machine that most recently ran a session for any of these cards. None +// when none of them has ever been at a cabinet +pub fn last_machine_of_cards(cards: &[String]) -> Option { + if cards.is_empty() { + return None; + } + let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?; + let sql = format!( + "SELECT last_machine_id FROM card_sessions WHERE card_id IN ({}) AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1", + placeholders(cards.len()) + ); + let mut stmt = conn.prepare(&sql).ok()?; + let args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect(); + let machine_id: String = stmt.query_row(args.as_slice(), |row| row.get(0)).ok()?; if machine_id.is_empty() { return None; } Some(machine_id) } -pub fn account_has_card(user_id: i64) -> bool { - DATABASE.lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok() -} - pub fn insert_play(machine_id: &str, user_id: i64, live_id: i64, level: i64, score: i64, rank: i64, cleared: bool) { DATABASE.lock_and_exec( "INSERT INTO plays (machine_id, user_id, live_id, level, score, rank, at, cleared) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", @@ -405,35 +380,29 @@ mod tests { assert!(get_machine(&id).is_none()); } - // A card names an account; a re-bind repoints it and drops the previous - // holder's cabinet record; plays are attributed to the cabinet the card last - // sat at and die with the machine + // A card's cabinet record follows the card: it is attributed to the cabinet + // the card last sat at, a re-link clears it, and it dies with the machine #[test] - fn a_card_names_an_account_and_its_last_cabinet() { + fn a_cards_cabinet_record_follows_the_card() { let _lock = crate::runtime::lock_test_data_path(); let machine = generate_machine_id(); insert_machine(&machine, "Cabinet 2", 333_333_333_333_333, 444_444_444_444_444); - let card = "0123456789012345"; + let cards = vec!["0123456789012345".to_string()]; + let card = cards[0].as_str(); - assert!(card_user(card).is_none()); - set_card(card, 555_555_555_555_555); - assert_eq!(card_user(card), Some(555_555_555_555_555)); - assert!(account_has_card(555_555_555_555_555)); // Never at a cabinet yet - assert!(last_machine_of_card_account(555_555_555_555_555).is_none()); + assert!(last_machine_of_cards(&cards).is_none()); + assert!(live_card_session(&[], 0).is_none()); let now = global::timestamp() as i64; open_card_session(card, &machine, now + 60); - assert_eq!(last_machine_of_card_account(555_555_555_555_555).as_deref(), Some(machine.as_str())); + assert_eq!(last_machine_of_cards(&cards).as_deref(), Some(machine.as_str())); - // A re-bind keeps the card, moves the account and forgets the cabinet - - // and the window the previous account's credit paid for - set_card(card, 666_666_666_666_666); - assert_eq!(card_user(card), Some(666_666_666_666_666)); - assert!(!account_has_card(555_555_555_555_555)); - assert!(last_machine_of_card_account(666_666_666_666_666).is_none()); - assert!(live_card_session(666_666_666_666_666, now).is_none(), "a re-bind carried the previous account's credit over"); + // A re-link forgets the cabinet and the window the previous account's credit paid for + clear_card_session(card); + assert!(last_machine_of_cards(&cards).is_none()); + assert!(live_card_session(&cards, now).is_none(), "a re-link carried the previous account's credit over"); assert_eq!(play_count(&machine), 0); insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true); @@ -450,53 +419,48 @@ mod tests { assert_eq!(ledger[0]["score"].as_i64(), Some(123_456)); assert_eq!(ledger[1]["cleared"].as_bool(), Some(true)); - // Removing the cabinet takes its ledger with it and unlinks the card + // Removing the cabinet takes its ledger and its card records with it open_card_session(card, &machine, now + 60); delete_machine(&machine); assert_eq!(play_count(&machine), 0); - assert!(last_machine_of_card_account(666_666_666_666_666).is_none()); - assert!(live_card_session(666_666_666_666_666, now).is_none(), "a retired cabinet left a credit open"); - assert_eq!(card_user(card), Some(666_666_666_666_666)); + assert!(last_machine_of_cards(&cards).is_none()); + assert!(live_card_session(&cards, now).is_none(), "a retired cabinet left a credit open"); } // The credit a card paid for is a window on its own row: open until it is // not, pushed forward but never backward, and never shared with another card - // of another account #[test] fn a_credit_opens_a_window_that_closes_on_its_own() { let _lock = crate::runtime::lock_test_data_path(); let machine = generate_machine_id(); insert_machine(&machine, "Cabinet 3", 777_777_777_777_777, 888_888_888_888_888); - let card = "1212121212121212"; - let user = 121_212_121_212_121; + let cards = vec!["1212121212121212".to_string()]; + let card = cards[0].as_str(); let now = global::timestamp() as i64; - // A mapping with no session behind it is not a cabinet session - set_card(card, user); - assert!(live_card_session(user, now).is_none()); + // A card with no session behind it is not a cabinet session + assert!(live_card_session(&cards, now).is_none()); open_card_session(card, &machine, now + 600); - let (open_card, opened) = live_card_session(user, now).expect("the credit did not open a window"); + let (open_card, opened) = live_card_session(&cards, now).expect("the credit did not open a window"); assert_eq!(open_card, card); assert!(opened <= now && opened >= now - 5, "the session start was not stamped: {} vs {}", opened, now); // The window closes by itself, with nothing to sweep - assert!(live_card_session(user, now + 599).is_some()); - assert!(live_card_session(user, now + 600).is_none(), "the window outlived its own expiry"); - assert!(live_card_session(user, now + 601).is_none()); + assert!(live_card_session(&cards, now + 599).is_some()); + assert!(live_card_session(&cards, now + 600).is_none(), "the window outlived its own expiry"); + assert!(live_card_session(&cards, now + 601).is_none()); // A live inside it pushes it forward, and only forward extend_card_session(card, now + 1200); - assert!(live_card_session(user, now + 900).is_some()); + assert!(live_card_session(&cards, now + 900).is_some()); extend_card_session(card, now + 300); - assert!(live_card_session(user, now + 900).is_some(), "an extension moved the window backwards"); + assert!(live_card_session(&cards, now + 900).is_some(), "an extension moved the window backwards"); - // Another account's card is untouched by any of it - let other_card = "3434343434343434"; - let other_user = 343_434_343_434_343; - set_card(other_card, other_user); - assert!(live_card_session(other_user, now).is_none()); + // Another card is untouched by any of it + let other = vec!["3434343434343434".to_string()]; + assert!(live_card_session(&other, now).is_none()); delete_machine(&machine); } diff --git a/src/router.rs b/src/router.rs index 6ed1171..12f2b63 100644 --- a/src/router.rs +++ b/src/router.rs @@ -190,7 +190,7 @@ fn not_found(req: &HttpRequest) -> HttpResponse { global::send(rv, 0, req) } -// Fallback for paths no actix route matched. Game endpoints live in each module's routes() +// Fallback async fn api_req(req: HttpRequest, body: String) -> HttpResponse { let args = crate::get_args(); if args.hidden && (req.path().starts_with("/api/webui/") || !(req.path().starts_with("/api") || req.path().starts_with("/v1.0"))) { @@ -225,8 +225,8 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/grantPermission" => webui::grant_permission(req, body), "/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/webui/linkNesicaCard" => webui::link_nesica_card(req, body), + "/api/webui/unlinkNesicaCard" => webui::unlink_nesica_card(req, body), _ => api_req(req, body).await } } else { @@ -247,7 +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/webui/listNesicaCards" => webui::list_nesica_cards(req), _ => api_req(req, body).await } } diff --git a/src/router/arcade.rs b/src/router/arcade.rs index 5f537fd..6eed6c5 100644 --- a/src/router/arcade.rs +++ b/src/router/arcade.rs @@ -1,50 +1,15 @@ // Arcade mode: a SIF2 client turned into a rhythm cabinet. -// -// A cabinet registers once and gets a machine id plus two accounts it owns -// forever - the MACHINE account (the identity the attract loop and its demo -// lives run on) and one reusable GUEST. Every credit calls /session; without a -// card that rewrites the guest back to the starter state in place, keeping its -// 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. 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 -// pinned to one normal play, so rewards, EXP, bonds, high scores and clears all -// record exactly as they do on a phone while LP is never touched. -// -// That flag is money, so it is not taken on trust. It buys a free play only for -// a machine's own two identities, or for a card account inside the window the -// credit at /api/arcade/session opened for it, and only when the /live/start -// this /live/end answers was itself flagged. See arcade_account_at. -// -// The whole feature is opt-in (--enable-arcade) and additionally off in -// --hidden mode. When disabled every endpoint answers like the custom-song -// endpoints do with their flag off - Api(None), as if it never existed - and -// nothing touches arcade.db, so no table setup runs. use jzon::{object, JsonValue}; use actix_web::{web, Responder}; -use crate::router::{databases, global, live, multi_live, userdata, Api, Body, Session}; +use crate::router::{databases, global, live, multi_live, userdata, Api, Body}; +use crate::router::userdata::user::migration; use crate::database::arcade as database; -// The name a cabinet falls back to when its operator sent nothing usable const DEFAULT_MACHINE_NAME: &str = "ARCADE"; - -// Guest accounts are created under this name and renamed to their cabinet's own -// name at the first session (design 4.3: a credit plays as the machine) const GUEST_NAME: &str = "GUEST"; -// NESiCA ids are 16 ASCII digits; the cap is generous enough for any other -// reader an I/O provider might present without letting an id become a blob -const MAX_CARD_ID_LEN: usize = 32; - pub fn routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/arcade") @@ -52,12 +17,6 @@ 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)) ); } @@ -66,60 +25,43 @@ pub fn disabled() -> bool { args.hidden || !args.enable_arcade } -// Days a machine may go unseen before --purge deletes it. 0 means never. +// 0 = forever fn machine_ttl_days() -> u64 { crate::get_args().arcade_machine_ttl } -// How long one credit buys LP-free play for the card that paid it. Long enough -// that a slow credit never runs out mid-song (the design's play is two songs), -// short enough that a card left on a reader overnight is not an open tap. fn session_ttl() -> i64 { crate::get_args().arcade_session_ttl as i64 * 60 } -// A live played inside the window pushes it back, so a credit that runs long is -// never cut off - but a credit is a credit, and past this many windows from the -// session that opened it the extension stops. Without a ceiling one tap of a -// card would buy LP-free lives for as long as the player kept starting them. const MAX_SESSION_WINDOWS: i64 = 4; -// The client writes its request-body flags as 0/1 ints (auto_play, is_omakase, -// ...), so `arcade` is read as either that or a JSON bool. fn flag(value: &JsonValue) -> bool { value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0 } -// 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 { - valid_card_id(body["card_id"].as_str().unwrap_or("")) + migration::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; +pub fn is_cabinet_account(user_id: i64) -> bool { + !disabled() && database::machine_of_account(user_id).is_some() +} + +pub fn card_relinked(card: &str) { + if !disabled() { + database::clear_card_session(card); } - if card_id.len() > MAX_CARD_ID_LEN || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) { - return None; - } - Some(card_id) +} + +fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> { + database::live_card_session(&migration::cards_of_account(user_id), now) } -// The credit is taken: this card is at this cabinet, and for the next ttl it -// plays the way a cabinet plays. The single place a window is ever opened - -// /api/arcade/session is the one moment the server is told a credit was spent. fn open_card_session(card: &str, machine_id: &str) { database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl()); } -// -- endpoints -------------------------------------------------------------- - -// The client asks this before it offers to convert a device: a server without -// the module answers None and the Title-menu entry refuses. async fn info() -> impl Responder { if disabled() { return Api(None); @@ -130,8 +72,6 @@ async fn info() -> impl Responder { })) } -// Converting a fresh device into a cabinet. Pre-login by nature: the device has -// no account yet, which is exactly the state the Title-menu entry requires. async fn register(Body(body): Body) -> impl Responder { if disabled() { return Api(None); @@ -142,8 +82,6 @@ async fn register(Body(body): Body) -> impl Responder { return Api(None); }; let Some((guest_user_id, guest_uuid)) = userdata::starter::create(GUEST_NAME) else { - // Half a cabinet is worse than none: the machine account has nothing - // pointing at it and nobody holding its token, so it goes back. userdata::delete_account(machine_user_id); return Api(None); }; @@ -161,29 +99,17 @@ 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 user_id = migration::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); + migration::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. -// -// 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); @@ -196,10 +122,6 @@ 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 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() { @@ -208,12 +130,6 @@ async fn session(Body(body): Body) -> impl Responder { } let Some(card) = card else { - // The cabinet's own guest, rewritten from scratch. Same user id, so the - // machine keeps its one guest forever - but a new login token, because - // the previous player had a credit's worth of time alone with the old - // one. The client adopts the uuid this answer carries (ArcadeEntranceScene - // OnSessionResponse -> MngArcadeData.AdoptIdentity), so the rotation is - // invisible to it. let guest_user_id = machine["guest_user_id"].as_i64().unwrap_or(0); let Some(uuid) = userdata::starter::reset(guest_user_id, &machine_name) else { println!("arcade: machine {} has no guest account to reset", machine_id); @@ -242,43 +158,15 @@ async fn session(Body(body): Body) -> impl Responder { })) } -// 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 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")); } - let Some(card) = card_id(&object!{ "card_id": card }) else { - return Err(String::from("That is not a usable card id")); - }; - // 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. 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")); - } - - database::set_card(&card, user_id); + migration::link_card(card, user_id)?; println!("arcade: card {} now plays as account {}", card, user_id); Ok(user_id) } -// The cabinet's bind: the proof that a phone account is the player's is the -// game's own data-transfer code and password. On a phone the transfer runs -// through GREE's native code, and ew's /api/user/gglverifymigrationcode is only -// the desktop route with GGL off; what both share is the `migration` table - -// the code and the hashed password - which get_acc_transfer is the one owner -// of. A cabinet is a Windows build with GGL off, so that pair is its legitimate -// path, typed in from the game's Data Transfer screen. pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result { if disabled() { return Err(String::from("Arcade mode is disabled on this server")); @@ -307,94 +195,8 @@ async fn bind(Body(body): Body) -> impl Responder { } } -// -- the phone's own cards -------------------------------------------------- +// lives -// 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); - } - let user_id = userdata::uid_from_login_token(&key); - if user_id == 0 { - return Api(None); - } - 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_id = userdata::uid_from_login_token(&key); - if user_id == 0 { - return Api(None); - } - 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 ------------------------------------------------------------------ - -// The account playing on a cabinet right now, None when the `arcade` flag is -// just a flag in a request body. -// -// The flag decides whether the play costs LP, so it is honoured for exactly two -// kinds of account: -// -// * one of a machine's own two identities. The cabinet holds both tokens, the -// guest is rewritten from scratch at every credit and the machine account -// only ever runs the attract loop, so a free play on either buys nobody -// anything. -// * an account a card is bound to, and only while the credit that card paid -// for is still running: /api/arcade/session opened a window on the card row -// and it has not closed yet. -// -// A card mapping on its own proves nothing - a player can bind a card to their -// own account from the webui account page without ever standing in front of a -// cabinet - so an account whose card is not at a machine right now is an -// ordinary phone account and pays LP, exactly as before. fn cabinet_account_at(login_token: &str, now: i64) -> Option { let user_id = userdata::uid_from_login_token(login_token); if user_id == 0 { @@ -403,12 +205,9 @@ fn cabinet_account_at(login_token: &str, now: i64) -> Option { if database::machine_of_account(user_id).is_some() { return Some(user_id); } - database::live_card_session(user_id, now).map(|_| user_id) + live_card_session(user_id, now).map(|_| user_id) } -// The same account rule behind the request's own opt-in flag, which /live/start -// and /live/end carry. The flag is read first so a phone play never reaches any -// of the arcade lookups, nor the module's own on/off switch. fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option { if !flag(&body["arcade"]) || disabled() { return None; @@ -416,14 +215,6 @@ fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option Option { let user_id = arcade_account_at(login_token, body, global::timestamp() as i64)?; let Some(started) = live::get_started_live(login_token, body) else { @@ -437,48 +228,31 @@ pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option { Some(user_id) } -// The cabinet a play is attributed to. A machine's own two accounts belong to -// it outright; a card account belongs to nobody, so it is credited to the -// machine that most recently ran a session for that card. fn play_machine(user_id: i64) -> Option { if let Some(machine) = database::machine_of_account(user_id) { return machine["machine_id"].as_str().map(str::to_string); } - database::last_machine_of_card_account(user_id) + database::last_machine_of_cards(&migration::cards_of_account(user_id)) } -// A live starting on a cabinet is a sighting for it: last_seen is what the TTL -// sweeper measures, and a machine in daily use must never age out under it. -// -// It is also the credit saying it is still going. A song that runs long - or a -// second song of the same credit - pushes the card's window back so the play it -// is part of cannot expire underneath it, up to the ceiling above. pub fn live_started(login_token: &str, body: &JsonValue) { let now = global::timestamp() as i64; let Some(user_id) = arcade_account_at(login_token, body, now) else { return; }; if let Some(machine_id) = play_machine(user_id) { database::touch_machine(&machine_id); } - if let Some((card, opened)) = database::live_card_session(user_id, now) { + if let Some((card, opened)) = live_card_session(user_id, now) { let ttl = session_ttl(); database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS)); } } -// Credits already paid for the play, so use_lp is no longer what it costs - it -// is only what every reward scales off (live.rs:781). Pinned here to one normal -// 1x play rather than taken from the request: a cabinet that never spends LP -// must not be able to ask for a 10x payout. pub fn live_end_body(body: &JsonValue) -> JsonValue { let mut rv = body.clone(); rv["use_lp"] = multi_live::boost_lp(1).into(); rv } -// The result rank the client's own result screen shows, derived from the live's -// own thresholds - the same _scoreC/_scoreB/_scoreA/_scoreS columns the score -// missions read (live.rs:465). 4 = S, 3 = A, 2 = B, 1 = C; 0 is below C, and is -// also what a custom song gets, having no official thresholds. fn score_rank(live_id: i64, score: i64) -> i64 { let live = &databases::LIVE_LIST[live_id.to_string()]; let mut rank = 0; @@ -492,18 +266,7 @@ fn score_rank(live_id: i64, score: i64) -> i64 { rank } -// A cabinet's failed song. The retire wire carries no `arcade` flag - the -// client's CJsonSendParamLiveRetire is master_live_id, level and live_score and -// nothing else (Protocol.cs:7298-7305) - so the proof that this was a cabinet -// play is the start it belongs to: start_live recorded the whole /live/start -// body, and a cabinet's start is flagged. The account rule on top is /live/end's, -// unchanged. -// -// Read before live.rs's live_retire, which sweeps the very record this reads. pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option { - // The start record is read before the module's on/off switch so an ordinary - // player's retire - whose start was never flagged - costs one lookup and - // nothing else, exactly as it did before the ledger learned about failures. let user_id = retire_user_at(login_token, body, global::timestamp() as i64)?; if disabled() { return None; @@ -519,17 +282,6 @@ fn retire_user_at(login_token: &str, body: &JsonValue, now: i64) -> Option cabinet_account_at(login_token, now) } -// One row in the cabinet's ledger, and a sighting for it. Records nothing when -// the account has no cabinet to attribute the play to - a card bound through the -// webui that has never been to a machine still plays, it is just not anyone's -// bookkeeping. -// -// `cleared` is false for a song reported at /live/retire because its life gauge -// emptied. It is recorded whatever its play_time, unlike the global clear-rate -// counter live.rs:30 gates at five seconds: that gate keeps a rage-quit out of a -// public board, while a credit's song is a song of that credit either way. The -// score is the one the retire carried, and the rank is what that score is worth -// against the live's own thresholds - `cleared` is what tells the two apart. pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) { let Some(machine_id) = play_machine(user_id) else { return; }; let live_id = body["master_live_id"].as_i64().unwrap_or(0); @@ -539,20 +291,14 @@ pub fn record_play(user_id: i64, body: &JsonValue, cleared: bool) { database::touch_machine(&machine_id); } -// -- maintenance ------------------------------------------------------------ +// maintenance -// Machines unseen for --arcade-machine-ttl days, deleted with the two accounts -// each of them owns. Run from the --purge sweep at boot. -// -// A card-bound player account is never touched here, and neither is a machine -// or guest account somebody has bound a card to: cards outlive cabinets by -// design, and the account behind one is a player's. pub fn purge_machines() -> usize { if disabled() { return 0; } let ttl = machine_ttl_days(); - // 0 is "never age a cabinet out", not "age every cabinet out this second" + // 0 = never if ttl == 0 { return 0; } @@ -570,7 +316,7 @@ fn purge_machines_before(cutoff: i64) -> usize { ); for key in ["machine_user_id", "guest_user_id"] { let user_id = machine[key].as_i64().unwrap_or(0); - if user_id != 0 && !database::account_has_card(user_id) { + if user_id != 0 && !migration::account_has_card(user_id) { userdata::delete_account(user_id); } } @@ -579,10 +325,8 @@ fn purge_machines_before(cutoff: i64) -> usize { dead.len() } -// -- webui ------------------------------------------------------------------ +// webui -// The operator's machine list: name, id, last seen and how many lives the -// cabinet has recorded. pub fn webui_machines() -> JsonValue { if disabled() { return jzon::array![]; @@ -590,8 +334,6 @@ pub fn webui_machines() -> JsonValue { database::list_machines() } -// Retiring a cabinet by hand: the same deletion the TTL sweeper performs, with -// the same rule about accounts a card has claimed. pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> { if disabled() { return Err(String::from("Arcade mode is disabled on this server")); @@ -601,7 +343,7 @@ pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> { }; for key in ["machine_user_id", "guest_user_id"] { let user_id = machine[key].as_i64().unwrap_or(0); - if user_id != 0 && !database::account_has_card(user_id) { + if user_id != 0 && !migration::account_has_card(user_id) { userdata::delete_account(user_id); } } @@ -610,6 +352,9 @@ pub fn webui_remove_machine(machine_id: &str) -> Result<(), String> { Ok(()) } + +// Rest of file is tests + #[cfg(test)] mod tests { use super::*; @@ -635,7 +380,6 @@ mod tests { assert!(card_id(&object!{ card_id: "" }).is_none()); assert!(card_id(&object!{ card_id: "0123-4567" }).is_none()); assert!(card_id(&object!{ card_id: "'; DROP TABLE cards--" }).is_none()); - assert!(card_id(&object!{ card_id: "x".repeat(MAX_CARD_ID_LEN + 1) }).is_none()); } // The rank stored in the ledger is the one the result screen shows, read off @@ -663,96 +407,22 @@ mod tests { // Nobody linked it: unlinked, and nothing was created for it assert!(resolve_card("7020392000000001").is_none()); - assert!(db::card_user("7020392000000001").is_none()); + assert!(migration::card_user("7020392000000001").is_none()); // Linked: the account and its token let (player, token) = userdata::starter::create("Linked").unwrap(); - db::set_card("7020392000000002", player); + migration::set_card("7020392000000002", player); assert_eq!(resolve_card("7020392000000002"), Some((player, token))); // 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"); + assert!(migration::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived"); } - // A player's cards are theirs to list and unlink, and nobody else's - #[test] - 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 + // bind_card_to is the cabinet's entrance to the shared link rule + // (migration::link_card): 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(); @@ -766,20 +436,20 @@ mod tests { // The id is validated, not repaired assert!(bind_card_to("0123-4567", player).is_err()); - assert!(db::card_user("0123-4567").is_none()); + assert!(migration::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()); + assert!(migration::card_user(card).is_none()); // 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); + migration::set_card(card, previous); assert_eq!(bind_card_to(card, player), Ok(player)); - assert_eq!(db::card_user(card), Some(player)); + assert_eq!(migration::card_user(card), Some(player)); 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); @@ -882,11 +552,11 @@ mod tests { let (claimed_guest, _) = userdata::starter::create(GUEST_NAME).unwrap(); let claimed_id = db::generate_machine_id(); db::insert_machine(&claimed_id, "Claimed cabinet", claimed_machine, claimed_guest); - db::set_card("4444333322221111", claimed_machine); + migration::set_card("4444333322221111", claimed_machine); // A player account with a card, belonging to no cabinet at all let (player, player_token) = userdata::starter::create("Player").unwrap(); - db::set_card("8888777766665555", player); + migration::set_card("8888777766665555", player); // Everything registered above is "seen now"; only the two we push back // are older than the cutoff @@ -909,7 +579,7 @@ mod tests { // The claimed cabinet is retired, but the account behind its card is not assert!(db::get_machine(&claimed_id).is_none()); assert_eq!(userdata::uid_from_login_token(&claimed_machine_token), claimed_machine); - assert_eq!(db::card_user("4444333322221111"), Some(claimed_machine)); + assert_eq!(migration::card_user("4444333322221111"), Some(claimed_machine)); // A card-bound player account is never a candidate in the first place assert_eq!(userdata::uid_from_login_token(&player_token), player); diff --git a/src/router/user.rs b/src/router/user.rs index 082e926..a817f51 100644 --- a/src/router/user.rs +++ b/src/router/user.rs @@ -17,6 +17,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) { .route("/migration", web::post().to(migration)) .route("/gglrequestmigrationcode", web::post().to(request_migration_code)) .route("/gglverifymigrationcode", web::post().to(verify_migration_code)) + .route("/migration/card/list", web::post().to(migration_card_list)) + .route("/migration/card/link", web::post().to(migration_card_link)) + .route("/migration/card/unlink", web::post().to(migration_card_unlink)) .route("/getregisteredplatformlist", web::post().to(getregisteredplatformlist)) .route("/sif/migrate", web::post().to(sif_migrate)) .route("/ss/migrate", web::post().to(sifas_migrate)) @@ -240,6 +243,46 @@ async fn request_migration_code(Body(body): Body) -> impl Responder { "twxuid": user["login_token"].to_string() })) } +async fn migration_card_list(Session { key, .. }: Session) -> impl Responder { + let user_id = userdata::uid_from_login_token(&key); + if user_id == 0 { + return Api(None); + } + Api(Some(object!{ + "card_ids": userdata::user::migration::cards_of_account(user_id) + })) +} + +async fn migration_card_link(Session { key, body }: Session) -> impl Responder { + let user_id = userdata::uid_from_login_token(&key); + if user_id == 0 { + return Api(None); + } + match userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), user_id) { + Ok((card_id, rebound)) => Api(Some(object!{ + "card_id": card_id, + "rebound": rebound + })), + Err(_) => Api(None) + } +} + +async fn migration_card_unlink(Session { key, body }: Session) -> impl Responder { + let user_id = userdata::uid_from_login_token(&key); + if user_id == 0 { + return Api(None); + } + let Some(card_id) = userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else { + return Api(None); + }; + if !userdata::user::migration::remove_card_of(&card_id, user_id) { + return Api(None); + } + Api(Some(object!{ + "card_id": card_id + })) +} + async fn migration(Body(body): Body) -> impl Responder { let user = userdata::get_name_and_rank(body["user_id"].to_string().parse::().unwrap()); diff --git a/src/router/userdata/user/migration.rs b/src/router/userdata/user/migration.rs index fb5c299..50fff0c 100644 --- a/src/router/userdata/user/migration.rs +++ b/src/router/userdata/user/migration.rs @@ -9,10 +9,6 @@ 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(); @@ -29,15 +25,7 @@ fn generate_token() -> 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. +// A linked NESiCA card id works as the transfer code; the password is still required pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue { let database = userdata::get_userdata_database(); let uid: i64 = if let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)) { @@ -46,7 +34,7 @@ pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue { } 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 { + let Some(uid) = valid_card_id(token).and_then(|card| card_user(&card)) else { return object!{success: false}; }; let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(uid)) else { @@ -64,22 +52,76 @@ pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue { object!{success: true, login_token: login_token, user_id: uid} } -// 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. +// Used by gree 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() + || valid_card_id(token).and_then(|card| card_user(&card)).is_some() +} + +pub fn valid_card_id(card_id: &str) -> Option { + let card_id = card_id.trim().to_string(); + if card_id.is_empty() || card_id.len() > 32 || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + Some(card_id) +} + +pub fn card_user(card_id: &str) -> Option { + userdata::get_userdata_database().lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok() +} + +pub fn cards_of_account(user_id: i64) -> Vec { + let Ok(conn) = rusqlite::Connection::open(userdata::get_userdata_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() +} + +pub fn account_has_card(user_id: i64) -> bool { + userdata::get_userdata_database().lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok() +} + +pub fn set_card(card_id: &str, user_id: i64) { + userdata::get_userdata_database().lock_and_exec( + "INSERT INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3) ON CONFLICT(card_id) DO UPDATE SET user_id=?2", + params!(card_id, user_id, crate::router::global::timestamp() as i64) + ); +} + +pub fn import_card(card_id: &str, user_id: i64, created: i64) { + userdata::get_userdata_database().lock_and_exec( + "INSERT OR IGNORE INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3)", + params!(card_id, user_id, created) + ); +} + +pub fn remove_card(card_id: &str) { + userdata::get_userdata_database().lock_and_exec("DELETE FROM cards WHERE card_id=?1", params!(card_id)); +} + +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 + } +} + +// The one rule for linking a card, whoever proved the account (game session, webui session, cabinet transfer pair) +pub fn link_card(card_id: &str, user_id: i64) -> Result<(String, bool), String> { + let Some(card) = valid_card_id(card_id) else { + return Err(String::from("That is not a usable card id")); + }; + if crate::router::arcade::is_cabinet_account(user_id) { + return Err(String::from("That account belongs to an arcade cabinet")); + } + let previous = card_user(&card); + set_card(&card, user_id); + crate::router::arcade::card_relinked(&card); + Ok((card, previous.is_some_and(|p| p != user_id))) } pub fn save_acc_transfer(uid: i64, password: &str) -> String { @@ -135,6 +177,13 @@ pub fn setup_sql(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> { PRIMARY KEY (user_id, token) ); ", [])?; + conn.execute(" + CREATE TABLE IF NOT EXISTS cards ( + card_id TEXT NOT NULL PRIMARY KEY, + user_id BIGINT NOT NULL, + created BIGINT NOT NULL + ); + ", [])?; let is_updated = conn.prepare("SELECT user_id FROM migration LIMIT 1;").is_ok(); if is_updated { return Ok(()); } println!("Upgrading migration table"); @@ -199,3 +248,101 @@ fn legacy_verify_password(password: &str, salted_hash: &str) -> bool { let input_hash = hasher.finalize(); input_hash.as_slice() == hashed_password } + +// whenever an ai touches this codebase it decides to write 200000 lines of tests + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_account_lists_and_unlinks_only_its_own_cards() { + let _lock = crate::runtime::lock_test_data_path(); + + let (mine, _) = userdata::starter::create("Mine").unwrap(); + let (theirs, _) = userdata::starter::create("Theirs").unwrap(); + assert!(cards_of_account(mine).is_empty()); + + set_card("7020392000000011", mine); + set_card("7020392000000012", mine); + set_card("7020392000000013", theirs); + assert_eq!(cards_of_account(mine), vec!["7020392000000011".to_string(), "7020392000000012".to_string()]); + assert_eq!(cards_of_account(theirs), vec!["7020392000000013".to_string()]); + assert!(account_has_card(mine)); + + assert!(!remove_card_of("7020392000000013", mine)); + assert_eq!(card_user("7020392000000013"), Some(theirs)); + assert!(!remove_card_of("7020392000000014", mine)); + + assert!(remove_card_of("7020392000000011", mine)); + assert!(card_user("7020392000000011").is_none()); + assert_eq!(cards_of_account(mine), vec!["7020392000000012".to_string()]); + + remove_card("7020392000000012"); + remove_card("7020392000000013"); + assert!(!account_has_card(mine)); + userdata::delete_account(mine); + userdata::delete_account(theirs); + } + + #[test] + fn card_ids_are_validated_not_sanitised() { + assert_eq!(valid_card_id("0123456789012345").as_deref(), Some("0123456789012345")); + assert_eq!(valid_card_id(" 0123456789012345 ").as_deref(), Some("0123456789012345")); + assert!(valid_card_id("").is_none()); + assert!(valid_card_id("0123-4567").is_none()); + assert!(valid_card_id("'; DROP TABLE cards--").is_none()); + assert!(valid_card_id(&"x".repeat(33)).is_none()); + } + + #[test] + fn link_card_repoints_a_linked_card_and_says_so() { + let _lock = crate::runtime::lock_test_data_path(); + + let (first, _) = userdata::starter::create("First").unwrap(); + let (second, _) = userdata::starter::create("Second").unwrap(); + let card = "7020392000000031"; + + assert!(link_card("7020-3920", first).is_err()); + assert_eq!(link_card(card, first), Ok((card.to_string(), false))); + assert_eq!(link_card(card, first), Ok((card.to_string(), false))); + assert_eq!(link_card(card, second), Ok((card.to_string(), true))); + assert_eq!(card_user(card), Some(second)); + assert!(!userdata::get_acc_from_uid(first)["error"].as_bool().unwrap_or(false)); + + remove_card(card); + userdata::delete_account(first); + userdata::delete_account(second); + } + + #[test] + fn a_linked_card_is_a_transfer_code_with_the_password_still_required() { + let _lock = crate::runtime::lock_test_data_path(); + + let (player, token) = userdata::starter::create("Card Transfer").unwrap(); + let card = "7020392000000021"; + + assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap()); + assert!(!transfer_code_exists(card)); + + 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)); + + 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())); + assert!(!get_acc_transfer(card, "wrong")["success"].as_bool().unwrap()); + let code = get_acc_token(player); + assert!(get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap()); + assert!(!code.chars().all(|c| c.is_ascii_digit())); + + remove_card(card); + assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap()); + + userdata::delete_account(player); + } +} diff --git a/src/router/webui.rs b/src/router/webui.rs index 22a5c68..0e83ad4 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -610,20 +610,18 @@ pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse { .body(jzon::stringify(resp)) } -pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { - if crate::router::arcade::disabled() { - return HttpResponse::NotFound().finish(); - } +pub fn link_nesica_card(req: HttpRequest, body: String) -> HttpResponse { 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_to(body["card_id"].as_str().unwrap_or(""), uid) { - Ok(user_id) => { + match crate::router::userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), uid) { + Ok((card_id, rebound)) => { let resp = object!{ result: "OK", data: { - user_id: user_id + card_id: card_id, + rebound: rebound } }; HttpResponse::Ok() @@ -634,18 +632,14 @@ 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(); - } +pub fn list_nesica_cards(req: HttpRequest) -> HttpResponse { 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) + card_ids: crate::router::userdata::user::migration::cards_of_account(uid) } }; HttpResponse::Ok() @@ -653,21 +647,15 @@ pub fn list_arcade_cards(req: HttpRequest) -> HttpResponse { .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(); - } +pub fn unlink_nesica_card(req: HttpRequest, body: String) -> HttpResponse { 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 { + let Some(card) = crate::router::userdata::user::migration::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) { + if !crate::router::userdata::user::migration::remove_card_of(&card, uid) { return error("That card is not linked to this account"); } let resp = object!{ diff --git a/webui b/webui index 823a326..831d0c3 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 823a326c6e55bbd0ce0a058babcaba0fef95df14 +Subproject commit 831d0c338a5db0e544040fe0b0b0bd4a893b0293