Fix some things with the new feature

This commit is contained in:
Ethan O'Brien
2026-08-26 15:19:10 -05:00
parent 9e5228e9f5
commit f881a73dbd
3 changed files with 97 additions and 31 deletions

View File

@@ -302,34 +302,27 @@ async fn session(Body(body): Body) -> impl Responder {
})) }))
} }
// Point a card at an existing (phone) account. The proof is the data-transfer // Point a card at a player account the caller has already identified. This is
// code and its password - the same check /api/user/gglverifymigrationcode makes // the rule every entrance shares - the card id is validated, a cabinet's own
// (user.rs:214-220), through the one function that owns that comparison. // identities are refused, the mapping is replaced and the throwaway account
// // the card was carrying is cleaned up - and the proof of *whose* account it is
// Shared by the cabinet's own /api/arcade/bind and the webui account page's // belongs to the caller: the cabinet's /api/arcade/bind takes the game's
// form: the browser cannot speak the game protocol (encrypted bodies behind the // data-transfer code and password (bind_card), the webui account page takes
// asset gate), so it gets its own entrance, not its own copy of the rule. // the signed-in session itself, which already proves the account.
pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, String> { pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> {
if disabled() { if disabled() {
return Err(String::from("Arcade mode is disabled on this server")); return Err(String::from("Arcade mode is disabled on this server"));
} }
let Some(card) = card_id(&object!{ "card_id": card }) else { let Some(card) = card_id(&object!{ "card_id": card }) else {
return Err(String::from("That is not a usable card id")); return Err(String::from("That is not a usable card id"));
}; };
let account = userdata::user::migration::get_acc_transfer(migration_code, pass);
if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 {
return Err(String::from("Transfer code and password don't match"));
}
let Some(user_id) = account["user_id"].as_i64() else {
return Err(String::from("Transfer code and password don't match"));
};
// A cabinet's own two identities are not player accounts and may never be // 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 // 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 // credit: a card pointing at it would outlive that reset, and /session hands
// out the account's current login token to whoever presents the card. The // out the account's current login token to whoever presents the card. Every
// proof this bind takes - a transfer code and password - is exactly what a // proof a bind can take - a transfer code and password, a webui login - is
// hostile client can register on a guest during its own credit. // 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() { if database::machine_of_account(user_id).is_some() {
return Err(String::from("That account belongs to an arcade cabinet")); return Err(String::from("That account belongs to an arcade cabinet"));
} }
@@ -351,6 +344,27 @@ pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, St
Ok(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"));
}
let account = userdata::user::migration::get_acc_transfer(migration_code, pass);
if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 {
return Err(String::from("Transfer code and password don't match"));
}
let Some(user_id) = account["user_id"].as_i64() else {
return Err(String::from("Transfer code and password don't match"));
};
bind_card_to(card, user_id)
}
async fn bind(Body(body): Body) -> impl Responder { async fn bind(Body(body): Body) -> impl Responder {
match bind_card( match bind_card(
body["card_id"].as_str().unwrap_or(""), body["card_id"].as_str().unwrap_or(""),
@@ -717,6 +731,51 @@ mod tests {
crate::database::arcade::delete_machine(&machine_id); crate::database::arcade::delete_machine(&machine_id);
} }
// bind_card_to is the rule under both entrances - the cabinet's transfer
// proof and the webui's session - so whatever named the account, a cabinet's
// own identities are refused, and a provable throwaway goes with its card
#[test]
fn bind_card_to_refuses_cabinet_identities_and_takes_the_throwaway_with_the_card() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
let (machine_account, _) = userdata::starter::create("Cabinet Bind").unwrap();
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
let machine_id = db::generate_machine_id();
db::insert_machine(&machine_id, "Cabinet Bind", machine_account, guest_account);
let (player, _) = userdata::starter::create("Player").unwrap();
// The id is validated, not repaired
assert!(bind_card_to("0123-4567", player).is_err());
assert!(db::card_user("0123-4567").is_none());
// Neither cabinet identity may sit behind a card
let card = "4242424242424242";
assert!(bind_card_to(card, machine_account).is_err());
assert!(bind_card_to(card, guest_account).is_err());
assert!(db::card_user(card).is_none());
// A throwaway the card was carrying leaves with it...
let (orphan, _) = userdata::starter::create("2424").unwrap();
db::set_card(card, orphan);
assert_eq!(bind_card_to(card, player), Ok(player));
assert_eq!(db::card_user(card), Some(player));
assert!(userdata::get_acc_from_uid(orphan)["error"].as_bool().unwrap_or(false), "the throwaway survived");
// ...but an account somebody played on stays
let (played, played_token) = userdata::starter::create("2425").unwrap();
let mut user = userdata::get_acc_from_uid(played);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
userdata::save_acc(&played_token, user);
let other = "2525252525252525";
db::set_card(other, played);
assert_eq!(bind_card_to(other, player), Ok(player));
assert_eq!(db::card_user(other), Some(player));
assert!(!userdata::get_acc_from_uid(played)["error"].as_bool().unwrap_or(false), "a played-on account followed its card");
db::delete_machine(&machine_id);
}
// A cabinet's song that ended with an empty life gauge is reported at // A cabinet's song that ended with an empty life gauge is reported at
// /live/retire, which carries no arcade flag - the start record is what // /live/retire, which carries no arcade flag - the start record is what
// proves it was a cabinet's - and it lands in the ledger as a failed song // proves it was a cabinet's - and it lands in the ledger as a failed song

View File

@@ -607,24 +607,22 @@ pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The account page's "bind arcade card" form. The cabinet's own endpoint speaks // The account page's "bind arcade card" form: the card id, and nothing else.
// the encrypted game protocol behind the asset gate, which a browser cannot, so // The webui session already proves whose account this is, so the card is bound
// this is the browser's door onto the same rule - arcade::bind_card, not a copy // to the signed-in account through arcade::bind_card_to - the same rule the
// of it. A webui session is required on top of the transfer code and password: // cabinet's /api/arcade/bind applies once its own proof, the transfer code and
// the page is behind login anyway, and the extra proof costs nothing. // password, has named the account. The cabinet endpoint speaks the encrypted
// game protocol behind the asset gate, which a browser cannot, so this is the
// browser's door onto that rule rather than a copy of it.
pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse { pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse {
if crate::router::arcade::disabled() { if crate::router::arcade::disabled() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
} }
if session_uid(&req).is_none() { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
} };
let body = jzon::parse(&body).unwrap_or(object!{}); let body = jzon::parse(&body).unwrap_or(object!{});
match crate::router::arcade::bind_card( match crate::router::arcade::bind_card_to(body["card_id"].as_str().unwrap_or(""), uid) {
body["card_id"].as_str().unwrap_or(""),
body["migrationCode"].as_str().unwrap_or(""),
body["pass"].as_str().unwrap_or("")
) {
Ok(user_id) => { Ok(user_id) => {
let resp = object!{ let resp = object!{
result: "OK", result: "OK",

View File

@@ -26,6 +26,7 @@ pub struct HostConfig {
pub enable_custom_songs: bool, pub enable_custom_songs: bool,
pub enable_custom_cards: bool, pub enable_custom_cards: bool,
pub enable_custom_3dmv: bool, pub enable_custom_3dmv: bool,
pub enable_arcade: bool,
} }
// Lets an embedding app (or the tests) enable the opt-in custom songs feature // Lets an embedding app (or the tests) enable the opt-in custom songs feature
@@ -42,6 +43,10 @@ pub fn set_enable_custom_3dmv(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled; HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled;
} }
pub fn set_enable_arcade(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_arcade = enabled;
}
// The --owner uids: the permission system's bootstrap grantors. Process-level // The --owner uids: the permission system's bootstrap grantors. Process-level
// state rather than db rows so they work on a fresh install and can't be // state rather than db rows so they work on a fresh install and can't be
// revoked through the webui // revoked through the webui
@@ -178,6 +183,9 @@ pub fn overlay_args(args: &mut crate::options::Args) {
if cfg.enable_custom_3dmv { if cfg.enable_custom_3dmv {
args.enable_custom_3dmv = true; args.enable_custom_3dmv = true;
} }
if cfg.enable_arcade {
args.enable_arcade = true;
}
} }
// idk why an ai put tests here but they are here now. Yay tests???? // idk why an ai put tests here but they are here now. Yay tests????
@@ -201,5 +209,6 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
set_enable_custom_songs(true); set_enable_custom_songs(true);
set_enable_custom_cards(true); set_enable_custom_cards(true);
set_enable_custom_3dmv(true); set_enable_custom_3dmv(true);
set_enable_arcade(true);
guard guard
} }