Redo card syncing

This commit is contained in:
Ethan O'Brien
2026-09-02 15:06:17 -05:00
parent 59193d92f4
commit 2d9ea66ee3
7 changed files with 387 additions and 575 deletions
+43 -373
View File
@@ -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<String> {
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<String> {
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<i64, String> {
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<i64, String> {
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<i64> {
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<i64> {
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<i64> {
if !flag(&body["arcade"]) || disabled() {
return None;
@@ -416,14 +215,6 @@ fn arcade_account_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i6
cabinet_account_at(login_token, now)
}
// The account id when this really is an arcade play, None when the live should
// run the ordinary way.
//
// On top of the account rule above, /live/end has to agree with the /live/start
// it belongs to. start_live recorded the whole start body (live.rs:331), so the
// flag it carried is still there to read: a live that began as an ordinary play
// ends as one however its /live/end body is flagged, and a client cannot turn a
// finished LP-paid play into a free one after the fact.
pub fn arcade_play_user(login_token: &str, body: &JsonValue) -> Option<i64> {
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<i64> {
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<String> {
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<i64> {
// 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<i64>
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);
+43
View File
@@ -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::<i64>().unwrap());
+174 -27
View File
@@ -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<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.
// 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<String> {
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<i64> {
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<String> {
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::<usize, String>(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);
}
}
+10 -22
View File
@@ -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!{