Compare commits

7 Commits

Author SHA1 Message Date
Ethan O'Brien
9e5228e9f5 wip stuff (idk) 2026-08-26 08:21:29 -05:00
Ethan O'Brien
1d12a9415c Oops 2026-08-23 16:09:49 -05:00
Ethan O'Brien
4dcf73e5d4 New asset version I guess 2026-08-23 15:25:14 -05:00
Ethan O'Brien
06c9273151 New asset hashes I guess I hope nothing breaks 2026-08-17 15:22:51 -05:00
Ethan O'Brien
d975b87799 Oh 2026-08-15 22:20:14 -05:00
Ethan O'Brien
cb40d74c2c Announcements working 2026-08-12 17:21:52 -05:00
Ethan O'Brien
8f7346d09e New asset hashes 2026-08-11 21:47:28 -05:00
47 changed files with 5999 additions and 473 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ ndk/
.DS_Store .DS_Store
custom_songs/ custom_songs/
custom_cards/ custom_cards/
/custom_3dmv/
# local-only trees — never commit (35GB between them) # local-only trees — never commit (35GB between them)
/android/ /android/

View File

@@ -15,6 +15,7 @@ services:
DISABLE_EXPORTS: false # Will disable account exports DISABLE_EXPORTS: false # Will disable account exports
#ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints) #ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints)
#ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints) #ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints)
#ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints)
#OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards #OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards
#PURGE: false # Purge dead user accounts on startup #PURGE: false # Purge dead user accounts on startup
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server) #IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)

View File

@@ -15,6 +15,8 @@ args=(
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports) [ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
add_opt() { add_opt() {
local value="$1" flag="$2" local value="$1" flag="$2"
@@ -34,6 +36,12 @@ add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
# Server owner uid(s) for the permission system (comma-separated) # Server owner uid(s) for the permission system (comma-separated)
add_opt "${OWNER:-}" --owner add_opt "${OWNER:-}" --owner
# Days an unseen arcade machine survives --purge
add_opt "${ARCADE_MACHINE_TTL:-}" --arcade-machine-ttl
# Minutes a card's credit buys LP-free play for after /api/arcade/session
add_opt "${ARCADE_SESSION_TTL:-}" --arcade-session-ttl
# Asset / image paths. # Asset / image paths.
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
add_opt "${MASTERDATA:-}" --masterdata add_opt "${MASTERDATA:-}" --masterdata

View File

@@ -1,4 +1,7 @@
pub mod gree; pub mod gree;
pub mod custom_song; pub mod custom_song;
pub mod custom_card; pub mod custom_card;
pub mod custom_3dmv;
pub mod permissions; pub mod permissions;
pub mod announcements;
pub mod arcade;

View File

@@ -0,0 +1,267 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, object, JsonValue};
use crate::router::global;
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("announcements.db", setup_tables);
}
// 1 = notice, 2 = update, 3 = bug
pub const CATEGORIES: &[i64] = &[1, 2, 3];
pub const TYPES: &[&str] = &["news", "event", "gacha", "maintenance", "shop", "others"];
pub fn is_valid_category(category: i64) -> bool {
CATEGORIES.contains(&category)
}
pub fn is_valid_type(kind: &str) -> bool {
TYPES.contains(&kind)
}
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
banner BLOB,
updated INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1,
published_at BIGINT NOT NULL,
created_by BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
").unwrap();
}
pub enum Banner {
Keep,
Clear,
Set(Vec<u8>)
}
fn row_to_json(row: &rusqlite::Row) -> rusqlite::Result<JsonValue> {
Ok(object!{
id: row.get::<usize, i64>(0)?,
category: row.get::<usize, i64>(1)?,
type: row.get::<usize, String>(2)?,
title: row.get::<usize, String>(3)?,
body: row.get::<usize, String>(4)?,
has_banner: row.get::<usize, i64>(5)? != 0,
updated: row.get::<usize, i64>(6)? != 0,
visible: row.get::<usize, i64>(7)? != 0,
published_at: row.get::<usize, i64>(8)?,
created_by: row.get::<usize, i64>(9)?,
created_at: row.get::<usize, i64>(10)?
})
}
const COLUMNS: &str = "id, category, type, title, body, banner IS NOT NULL, updated, visible, published_at, created_by, created_at";
fn query(where_clause: &str, args: &[&dyn rusqlite::ToSql]) -> JsonValue {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let sql = format!("SELECT {COLUMNS} FROM announcements {where_clause} ORDER BY published_at DESC, id DESC");
let Ok(mut stmt) = conn.prepare(&sql) else {
return array![];
};
let Ok(mapped) = stmt.query_map(args, |row| row_to_json(row)) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
pub fn list_category(category: i64) -> JsonValue {
query("WHERE visible=1 AND category=?1", params!(category))
}
pub fn get_all() -> JsonValue {
query("", params!())
}
pub fn get(id: i64) -> Option<JsonValue> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let sql = format!("SELECT {COLUMNS} FROM announcements WHERE id=?1");
conn.query_row(&sql, params!(id), |row| row_to_json(row)).ok()
}
pub fn get_banner(id: i64) -> Option<Vec<u8>> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
}
pub fn get_public_banner(id: i64) -> Option<Vec<u8>> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND visible=1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
}
pub fn visible_ids(category: Option<i64>) -> Vec<i64> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let (sql, args): (&str, Vec<&dyn rusqlite::ToSql>) = match &category {
Some(c) => ("SELECT id FROM announcements WHERE visible=1 AND category=?1", vec![c]),
None => ("SELECT id FROM announcements WHERE visible=1", vec![])
};
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
let Ok(mapped) = stmt.query_map(args.as_slice(), |row| row.get::<usize, i64>(0)) else {
return Vec::new();
};
mapped.flatten().collect()
}
pub fn latest_published_at() -> i64 {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT MAX(published_at) FROM announcements WHERE visible=1", params!(), |row| row.get::<usize, i64>(0)).unwrap_or(0)
}
pub fn create(category: i64, kind: &str, title: &str, body: &str, banner: Option<Vec<u8>>, updated: bool, visible: bool, published_at: i64, created_by: i64) -> i64 {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute(
"INSERT INTO announcements (category, type, title, body, banner, updated, visible, published_at, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params!(category, kind, title, body, banner, updated as i64, visible as i64, published_at, created_by, global::timestamp() as i64)
).unwrap();
conn.last_insert_rowid()
}
pub fn update(id: i64, category: i64, kind: &str, title: &str, body: &str, banner: Banner, updated: bool, visible: bool, published_at: i64) {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute(
"UPDATE announcements SET category=?2, type=?3, title=?4, body=?5, updated=?6, visible=?7, published_at=?8 WHERE id=?1",
params!(id, category, kind, title, body, updated as i64, visible as i64, published_at)
).unwrap();
match banner {
Banner::Keep => {},
Banner::Clear => { conn.execute("UPDATE announcements SET banner=NULL WHERE id=?1", params!(id)).unwrap(); },
Banner::Set(bytes) => { conn.execute("UPDATE announcements SET banner=?2 WHERE id=?1", params!(id, bytes)).unwrap(); }
}
}
pub fn delete(id: i64) {
DATABASE.lock_and_exec("DELETE FROM announcements WHERE id=?1", params!(id));
}
/// more tests??? This is probably a good thing but man haha
#[cfg(test)]
mod tests {
use super::*;
fn wipe() {
DATABASE.lock_and_exec("DELETE FROM announcements", params!());
}
#[test]
fn create_list_and_category_filter() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let a = create(1, "news", "First", "<p>body</p>", None, false, true, 1000, 42);
let b = create(1, "event", "Second", "body", Some(vec![1, 2, 3]), true, true, 2000, 42);
let c = create(2, "gacha", "Other tab", "body", None, false, true, 1500, 42);
let hidden = create(1, "news", "Draft", "body", None, false, false, 3000, 42);
// One tab, visible only, newest first
let cat1 = list_category(1);
assert_eq!(cat1.len(), 2);
assert_eq!(cat1[0]["id"].as_i64(), Some(b));
assert_eq!(cat1[1]["id"].as_i64(), Some(a));
assert!(cat1.members().all(|row| row["id"].as_i64() != Some(hidden)));
// has_banner reflects the blob without carrying it
assert_eq!(cat1[0]["has_banner"].as_bool(), Some(true));
assert_eq!(cat1[1]["has_banner"].as_bool(), Some(false));
assert_eq!(get_banner(b), Some(vec![1, 2, 3]));
assert_eq!(get_banner(a), None);
// The other tab is untouched, the admin view sees the draft too
assert_eq!(list_category(2).len(), 1);
assert_eq!(list_category(2)[0]["id"].as_i64(), Some(c));
assert_eq!(get_all().len(), 4);
assert_eq!(latest_published_at(), 2000);
let mut visible = visible_ids(None);
visible.sort();
assert_eq!(visible, vec![a, b, c]);
let mut cat1_ids = visible_ids(Some(1));
cat1_ids.sort();
assert_eq!(cat1_ids, vec![a, b]);
wipe();
}
#[test]
fn update_rewrites_and_banner_transitions() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let id = create(1, "news", "Title", "body", Some(vec![9]), false, true, 1000, 7);
update(id, 3, "maintenance", "New title", "new body", Banner::Keep, true, true, 5000);
let row = get(id).unwrap();
assert_eq!(row["category"].as_i64(), Some(3));
assert_eq!(row["type"].as_str(), Some("maintenance"));
assert_eq!(row["title"].as_str(), Some("New title"));
assert_eq!(row["updated"].as_bool(), Some(true));
assert_eq!(row["published_at"].as_i64(), Some(5000));
// Keep left the blob in place
assert_eq!(get_banner(id), Some(vec![9]));
update(id, 3, "maintenance", "New title", "new body", Banner::Set(vec![4, 5]), true, true, 5000);
assert_eq!(get_banner(id), Some(vec![4, 5]));
update(id, 3, "maintenance", "New title", "new body", Banner::Clear, true, false, 5000);
assert_eq!(get_banner(id), None);
assert_eq!(get(id).unwrap()["visible"].as_bool(), Some(false));
delete(id);
assert!(get(id).is_none());
wipe();
}
// Ids are sequential, so the player-facing banner route is pollable: a
// draft's banner must be reachable by the admin lookup and by nothing else
#[test]
fn a_drafts_banner_is_admin_only() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let published = create(1, "news", "Live", "body", Some(vec![1, 2]), false, true, 1000, 42);
let draft = create(1, "news", "Unannounced", "body", Some(vec![7, 7]), false, false, 2000, 42);
assert_eq!(get_banner(draft), Some(vec![7, 7]));
assert_eq!(get_public_banner(draft), None);
assert_eq!(get_public_banner(published), Some(vec![1, 2]));
// Publishing it makes the banner reachable, hiding it again takes it back
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, true, 2000);
assert_eq!(get_public_banner(draft), Some(vec![7, 7]));
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, false, 2000);
assert_eq!(get_public_banner(draft), None);
wipe();
}
#[test]
fn vocabulary_is_wellformed() {
for category in CATEGORIES {
assert!(is_valid_category(*category));
}
assert!(!is_valid_category(0));
assert!(!is_valid_category(4));
for kind in TYPES {
assert!(is_valid_type(kind));
}
assert!(!is_valid_type("explosion"));
}
}

474
src/database/arcade.rs Normal file
View File

@@ -0,0 +1,474 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, object, JsonValue};
use rand::RngExt;
use crate::router::global;
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("arcade.db", setup_tables);
}
// A cabinet owns exactly two accounts forever: the MACHINE account (the identity
// the attract loop and its demo lives run on) and one reusable GUEST account,
// 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.
//
// `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
// reports it at /live/retire rather than /live/end (there is no cleared flag on
// the end wire and live_end_ex records a clear unconditionally), so the retire is
// the only place the ledger can learn about a failed song. A credit's play is two
// songs whether they were passed or not, which is what makes the total the number
// an operator's bookkeeping counts.
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS machines (
machine_id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
machine_user_id BIGINT NOT NULL,
guest_user_id BIGINT NOT NULL,
created BIGINT NOT NULL,
last_seen BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS cards (
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
);
CREATE TABLE IF NOT EXISTS plays (
id INTEGER PRIMARY KEY,
machine_id TEXT NOT NULL,
user_id BIGINT NOT NULL,
live_id BIGINT NOT NULL,
level INT NOT NULL,
score BIGINT NOT NULL,
rank INT NOT NULL,
at BIGINT NOT NULL,
cleared INTEGER NOT NULL DEFAULT 1
);
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();
}
}
// 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();
let id: String = (0..16)
.map(|_| CHARSET[rng.random_range(0..CHARSET.len())] as char)
.collect();
if machine_exists(&id) {
return generate_machine_id();
}
id
}
pub fn machine_exists(machine_id: &str) -> bool {
DATABASE.lock_and_select("SELECT machine_id FROM machines WHERE machine_id=?1", params!(machine_id)).is_ok()
}
pub fn insert_machine(machine_id: &str, name: &str, machine_user_id: i64, guest_user_id: i64) {
let now = global::timestamp() as i64;
DATABASE.lock_and_exec(
"INSERT INTO machines (machine_id, name, machine_user_id, guest_user_id, created, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
params!(machine_id, name, machine_user_id, guest_user_id, now)
);
}
fn machine_row(conn: &rusqlite::Connection, machine_id: &str) -> Option<JsonValue> {
let mut stmt = conn.prepare("SELECT machine_id, name, machine_user_id, guest_user_id, created, last_seen FROM machines WHERE machine_id=?1").ok()?;
stmt.query_row(params!(machine_id), |row| {
Ok(object!{
machine_id: row.get::<usize, String>(0)?,
name: row.get::<usize, String>(1)?,
machine_user_id: row.get::<usize, i64>(2)?,
guest_user_id: row.get::<usize, i64>(3)?,
created: row.get::<usize, i64>(4)?,
last_seen: row.get::<usize, i64>(5)?
})
}).ok()
}
pub fn get_machine(machine_id: &str) -> Option<JsonValue> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
machine_row(&conn, machine_id)
}
// The cabinet is alive: every session (and every play it records) stamps it, and
// the purge sweeper measures a machine's age from here
pub fn touch_machine(machine_id: &str) {
DATABASE.lock_and_exec("UPDATE machines SET last_seen=?1 WHERE machine_id=?2", params!(global::timestamp() as i64, machine_id));
}
// The machine a user account belongs to, when the account IS one of a cabinet's
// own two identities. A card-bound player account is nobody's, and answers None
pub fn machine_of_account(user_id: i64) -> Option<JsonValue> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
let mut stmt = conn.prepare("SELECT machine_id FROM machines WHERE machine_user_id=?1 OR guest_user_id=?1").ok()?;
let machine_id: String = stmt.query_row(params!(user_id), |row| row.get(0)).ok()?;
machine_row(&conn, &machine_id)
}
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 machines WHERE machine_id=?1", params!(machine_id));
}
// Every machine with its play count, newest sighting first - the webui list
pub fn list_machines() -> JsonValue {
let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else {
return array![];
};
let Ok(mut stmt) = conn.prepare("
SELECT m.machine_id, m.name, m.machine_user_id, m.guest_user_id, m.created, m.last_seen,
(SELECT COUNT(*) FROM plays p WHERE p.machine_id = m.machine_id),
(SELECT COUNT(*) FROM plays p WHERE p.machine_id = m.machine_id AND p.cleared <> 0)
FROM machines m ORDER BY m.last_seen DESC
") else {
return array![];
};
let Ok(mapped) = stmt.query_map(params!(), |row| {
Ok(object!{
machine_id: row.get::<usize, String>(0)?,
name: row.get::<usize, String>(1)?,
machine_user_id: row.get::<usize, i64>(2)?,
guest_user_id: row.get::<usize, i64>(3)?,
created: row.get::<usize, i64>(4)?,
last_seen: row.get::<usize, i64>(5)?,
play_count: row.get::<usize, i64>(6)?,
cleared_count: row.get::<usize, i64>(7)?
})
}) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
// Machines whose last sighting is older than `cutoff`. The two account ids come
// back with them: the sweeper deletes the accounts in the same pass
pub fn machines_last_seen_before(cutoff: i64) -> JsonValue {
let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else {
return array![];
};
let Ok(mut stmt) = conn.prepare("SELECT machine_id, machine_user_id, guest_user_id, last_seen FROM machines WHERE last_seen < ?1") else {
return array![];
};
let Ok(mapped) = stmt.query_map(params!(cutoff), |row| {
Ok(object!{
machine_id: row.get::<usize, String>(0)?,
machine_user_id: row.get::<usize, i64>(1)?,
guest_user_id: row.get::<usize, i64>(2)?,
last_seen: row.get::<usize, i64>(3)?
})
}) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
pub fn card_user(card_id: &str) -> Option<i64> {
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
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)
);
}
// 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)> {
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::<usize, String>(0)?, row.get::<usize, i64>(1)?))).ok()
}
// A live starting inside the window pushes its end back, so a credit whose songs
// run long is not cut off mid-play. Only ever forward, and never past the
// ceiling the caller computed from the session itself: extending without a
// 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<?1",
params!(until, card_id)
);
}
// Only the session tests need to age a card's window; production code only ever
// opens one at a session or pushes it forward through extend_card_session
#[cfg(test)]
pub fn backdate_card_session_for_test(card_id: &str, last_session: i64, session_until: i64) {
DATABASE.lock_and_exec(
"UPDATE cards SET last_session=?1, session_until=?2 WHERE card_id=?3",
params!(last_session, session_until, card_id)
);
}
// The machine that most recently ran a session for this account through any of
// its cards. Empty when the account has no card, or has never been at a cabinet
pub fn last_machine_of_card_account(user_id: i64) -> Option<String> {
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()?;
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)",
params!(machine_id, user_id, live_id, level, score, rank, global::timestamp() as i64, i64::from(cleared))
);
}
// Only the sweeper's own test needs to move a cabinet's clock; production code
// only ever stamps last_seen forward through touch_machine
#[cfg(test)]
pub fn backdate_machine_for_test(machine_id: &str, last_seen: i64) {
DATABASE.lock_and_exec("UPDATE machines SET last_seen=?1 WHERE machine_id=?2", params!(last_seen, machine_id));
}
pub fn play_count(machine_id: &str) -> i64 {
DATABASE.lock_and_select_type("SELECT COUNT(*) FROM plays WHERE machine_id=?1", params!(machine_id)).unwrap_or(0)
}
// The cabinet's ledger, newest first. Only the tests read it back today - the
// webui counts rows rather than listing them - but the ledger exists to be read
pub fn plays_of_machine(machine_id: &str) -> JsonValue {
let Ok(conn) = rusqlite::Connection::open(DATABASE.get_path()) else {
return array![];
};
let Ok(mut stmt) = conn.prepare(
"SELECT user_id, live_id, level, score, rank, at, cleared FROM plays WHERE machine_id=?1 ORDER BY id DESC"
) else {
return array![];
};
let Ok(mapped) = stmt.query_map(params!(machine_id), |row| {
Ok(object!{
user_id: row.get::<usize, i64>(0)?,
live_id: row.get::<usize, i64>(1)?,
level: row.get::<usize, i64>(2)?,
score: row.get::<usize, i64>(3)?,
rank: row.get::<usize, i64>(4)?,
at: row.get::<usize, i64>(5)?,
cleared: row.get::<usize, i64>(6)? != 0
})
}) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
#[cfg(test)]
mod tests {
use super::*;
// A cabinet owns its two accounts, is found from either of them, and its
// last_seen is what the purge sweeper measures
#[test]
fn a_machine_owns_its_two_accounts() {
let _lock = crate::runtime::lock_test_data_path();
let id = generate_machine_id();
assert_eq!(id.len(), 16);
assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
insert_machine(&id, "Cabinet 1", 111_111_111_111_111, 222_222_222_222_222);
let machine = get_machine(&id).unwrap();
assert_eq!(machine["name"].as_str(), Some("Cabinet 1"));
assert_eq!(machine["machine_user_id"].as_i64(), Some(111_111_111_111_111));
assert_eq!(machine["guest_user_id"].as_i64(), Some(222_222_222_222_222));
// Either of the two identities finds the cabinet; a stranger does not
assert_eq!(machine_of_account(111_111_111_111_111).unwrap()["machine_id"].as_str(), Some(id.as_str()));
assert_eq!(machine_of_account(222_222_222_222_222).unwrap()["machine_id"].as_str(), Some(id.as_str()));
assert!(machine_of_account(999_999_999_999_999).is_none());
// Aged out by the sweeper's cutoff, and only then
let seen = machine["last_seen"].as_i64().unwrap();
assert!(machines_last_seen_before(seen + 1).members().any(|m| m["machine_id"] == id.as_str()));
assert!(!machines_last_seen_before(seen).members().any(|m| m["machine_id"] == id.as_str()));
delete_machine(&id);
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
#[test]
fn a_card_names_an_account_and_its_last_cabinet() {
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";
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());
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()));
// 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");
assert_eq!(play_count(&machine), 0);
insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true);
insert_play(&machine, 666_666_666_666_666, 1100101, 4, 123_456, 2, false);
assert_eq!(play_count(&machine), 2);
// A failed song is a song: it is in the ledger and in the total, and the
// cleared count is what separates the two
assert!(list_machines().members().any(|m|
m["machine_id"] == machine.as_str() && m["play_count"] == 2 && m["cleared_count"] == 1
));
let ledger = plays_of_machine(&machine);
assert_eq!(ledger.len(), 2);
assert_eq!(ledger[0]["cleared"].as_bool(), Some(false));
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
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));
}
// 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 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());
open_card_session(card, &machine, now + 600);
let (open_card, opened) = live_card_session(user, 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());
// 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());
extend_card_session(card, now + 300);
assert!(live_card_session(user, 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());
delete_machine(&machine);
}
}

217
src/database/custom_3dmv.rs Normal file
View File

@@ -0,0 +1,217 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, JsonValue};
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("custom_3dmv.db", setup_tables);
}
// mv_id is its own namespace: it never appears where a music_id or a card id
// could, so the band only has to avoid colliding with itself. Ids are never
// reused after a delete (high-water mark below), so a client's cached copy of
// a dead id can't get confused with a later upload
pub const FIRST_MV_ID: i64 = 20001;
pub const LAST_MV_ID: i64 = 99_999;
// One JSON blob per MV, in the exact shape /api/custom_3dmv/list serves -
// except `published`, which lives in its own column (the catalog filter
// queries it) and is only injected for the webui manage view
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS mvs (
mv_id BIGINT NOT NULL PRIMARY KEY,
music_id BIGINT NOT NULL,
owner_id BIGINT NOT NULL,
mv TEXT NOT NULL,
published INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS revision (
id INT NOT NULL PRIMARY KEY,
revision BIGINT NOT NULL,
last_mv_id BIGINT NOT NULL
);
").unwrap();
}
pub fn get_revision() -> i64 {
DATABASE.lock_and_select("SELECT revision FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0)
}
// Bumped on every upload/update/delete/publish change so the client can tell
// its cached catalog is stale
pub fn bump_revision() {
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 1, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!());
}
// last_mv_id is the high-water mark and only ever rises, so MAX() over the
// live rows is a floor, not the answer
pub fn next_mv_id() -> i64 {
let issued = DATABASE.lock_and_select("SELECT last_mv_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
let max = DATABASE.lock_and_select("SELECT MAX(mv_id) FROM mvs", params!()).unwrap_or_default().parse::<i64>().unwrap_or(0);
std::cmp::max(std::cmp::max(issued, max), FIRST_MV_ID - 1) + 1
}
pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) {
DATABASE.lock_and_exec(
"INSERT INTO mvs (mv_id, music_id, owner_id, mv, published) VALUES (?1, ?2, ?3, ?4, ?5)",
params!(mv_id, music_id, owner_id, jzon::stringify(mv.clone()), published as i64)
);
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_mv_id=?1", params!(mv_id));
}
// The catalog blob only. The owner and the published flag live in their own
// columns and are untouched here; music_id is fixed for the life of the MV
pub fn update_mv(mv_id: i64, mv: &JsonValue) {
DATABASE.lock_and_exec("UPDATE mvs SET mv=?1 WHERE mv_id=?2", params!(jzon::stringify(mv.clone()), mv_id));
}
pub fn delete_mv(mv_id: i64) {
DATABASE.lock_and_exec("DELETE FROM mvs WHERE mv_id=?1", params!(mv_id));
}
pub fn get_mv(mv_id: i64) -> Option<JsonValue> {
let mv = DATABASE.lock_and_select("SELECT mv FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?;
jzon::parse(&mv).ok()
}
pub fn get_mv_owner(mv_id: i64) -> Option<i64> {
DATABASE.lock_and_select("SELECT owner_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::<i64>().ok()
}
pub fn get_mv_music_id(mv_id: i64) -> Option<i64> {
DATABASE.lock_and_select("SELECT music_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::<i64>().ok()
}
pub fn is_published(mv_id: i64) -> bool {
DATABASE.lock_and_select("SELECT published FROM mvs WHERE mv_id=?1", params!(mv_id)).unwrap_or_default() == "1"
}
pub fn set_published(mv_id: i64, published: bool) {
DATABASE.lock_and_exec("UPDATE mvs SET published=?1 WHERE mv_id=?2", params!(published as i64, mv_id));
}
pub fn mv_count_for_owner(owner_id: i64) -> i64 {
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM mvs WHERE owner_id=?1", params!(owner_id)).unwrap_or(0)
}
fn parse_blobs(rows: JsonValue) -> JsonValue {
let mut rv = array![];
for data in rows.members() {
if let Ok(parsed) = jzon::parse(&data.to_string()) {
rv.push(parsed).unwrap();
}
}
rv
}
// The MV catalog this user is served: every published MV plus their own
// drafts, filtered against `music_ids` - the music ids the SAME user's
// custom-song catalog delivers. The closure is what keeps the response
// referentially sound: a served MV must never name a music_id the song
// catalog failed to deliver (a published MV for someone else's private song
// stays invisible)
pub fn get_mvs_for_user(user_id: i64, music_ids: &[i64]) -> JsonValue {
let rows = parse_blobs(DATABASE.lock_and_select_all(
"SELECT mv FROM mvs WHERE published=1 OR owner_id=?1 ORDER BY mv_id",
params!(user_id)
).unwrap_or(array![]));
let mut rv = array![];
for mv in rows.members() {
if music_ids.contains(&mv["music_id"].as_i64().unwrap_or(0)) {
rv.push(mv.clone()).unwrap();
}
}
rv
}
// MV blobs plus the flag column, for the webui manage view
pub fn get_mvs_by_owner(owner_id: i64) -> JsonValue {
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE owner_id=?1 ORDER BY mv_id", params!(owner_id)).unwrap_or(array![]));
let mut rv = array![];
for mv in rows.members() {
let mut mv = mv.clone();
mv["published"] = is_published(mv["mv_id"].as_i64().unwrap_or(0)).into();
rv.push(mv).unwrap();
}
rv
}
// The webui MV browser: published MVs whose song the viewer can see, plus the
// owner id so the page can label the uploader
pub fn get_browse_mvs(viewer_music_ids: &[i64]) -> JsonValue {
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE published=1 ORDER BY mv_id", params!()).unwrap_or(array![]));
let mut rv = array![];
for mv in rows.members() {
if !viewer_music_ids.contains(&mv["music_id"].as_i64().unwrap_or(0)) {
continue;
}
let mut mv = mv.clone();
mv["owner_id"] = get_mv_owner(mv["mv_id"].as_i64().unwrap_or(0)).unwrap_or(0).into();
rv.push(mv).unwrap();
}
rv
}
// Every MV attached to a song, for the delete cascade
pub fn mv_ids_for_music(music_id: i64) -> Vec<i64> {
let rows = DATABASE.lock_and_select_all("SELECT mv_id FROM mvs WHERE music_id=?1 ORDER BY mv_id", params!(music_id)).unwrap_or(array![]);
rows.members().filter_map(|id| id.as_i64()).collect()
}
// Which of these candidate ids no longer exist. Only the MV band is ever
// considered and ids are never reused, so a wipe is final. An MV that's
// merely unpublished still has its row - only genuinely deleted ids return
pub fn dead_mv_ids(candidates: &JsonValue) -> JsonValue {
let mut ids: Vec<i64> = Vec::new();
for id in candidates.members() {
let Some(id) = id.as_i64() else { continue; };
if (FIRST_MV_ID..=LAST_MV_ID).contains(&id) && !ids.contains(&id) {
ids.push(id);
}
}
if ids.is_empty() {
return array![];
}
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let alive = DATABASE.lock_and_select_all(&format!("SELECT mv_id FROM mvs WHERE mv_id IN ({})", list), params!()).unwrap_or(array![]);
let mut rv = array![];
for id in ids {
if !alive.contains(id) {
rv.push(id).unwrap();
}
}
rv
}
// Every stored catalog blob, unparsed and unfiltered. Only the startup blob
// sweep needs the whole table, and a read failure must never look like an
// empty catalog (the sweep would then delete every blob), so this returns
// None rather than an empty array on error
pub fn all_mv_blobs() -> Option<JsonValue> {
DATABASE.lock_and_select_all("SELECT mv FROM mvs ORDER BY mv_id", params!()).ok()
}
// Blobs are content-addressed and may be shared between MVs (or roles), so
// every candidate row is checked - a single-row scan could land on a
// coincidental substring match and miss the real reference
pub fn blob_in_use(md5: &str) -> bool {
let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5))).unwrap_or(array![]);
for blob in rows.members() {
if let Ok(mv) = jzon::parse(&blob.to_string()) {
if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) {
return true;
}
}
}
false
}
// Two-step content-addressed lookup for the data route: the LIKE scan finds a
// candidate row cheaply, then the files array confirms the md5 is really a
// stored file of a live MV (and not a substring coincidence elsewhere in the
// blob). The blob path itself derives from the md5
pub fn find_blob_by_md5(md5: &str) -> bool {
blob_in_use(md5)
}

View File

@@ -257,6 +257,14 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
Some(if english && !name_en.is_empty() { name_en } else { name }) Some(if english && !name_en.is_empty() { name_en } else { name })
} }
// Whether the song exists and is publicly visible - what lets another
// uploader attach cross-feature content (a custom 3D MV) to it. The
// existence check comes first: get_visibility defaults to "public" for an
// absent row
pub fn song_publicly_visible(music_id: i64) -> bool {
get_song_owner(music_id).is_some() && get_visibility(music_id) == "public"
}
pub fn get_music_ids_for_user(user_id: i64) -> JsonValue { pub fn get_music_ids_for_user(user_id: i64) -> JsonValue {
DATABASE.lock_and_select_all(" DATABASE.lock_and_select_all("
SELECT music_id FROM songs SELECT music_id FROM songs

View File

@@ -9,38 +9,34 @@ lazy_static! {
static ref DATABASE: SQLite = SQLite::new("permissions.db", setup_tables); static ref DATABASE: SQLite = SQLite::new("permissions.db", setup_tables);
} }
// Scopes are flat dotted strings and imply everything below them: holding
// "card" grants "card.upload", and "*" grants everything. That hierarchy is
// the only notion of "level" - there is no rank integer, so a new capability
// is one line here and never a renumbering of anything else.
//
// Every call site must pass one of these consts, never a literal: an
// unrecognised scope string can only ever fail closed in has(), but grant()
// rejects it outright so a typo can't be persisted either.
pub const ALL: &str = "*"; pub const ALL: &str = "*";
pub const CARD: &str = "card"; pub const CARD: &str = "card";
// Create custom cards/characters, and edit or delete your OWN uploads
pub const CARD_UPLOAD: &str = "card.upload"; pub const CARD_UPLOAD: &str = "card.upload";
// Publish/unpublish and mark obtainable, on your OWN uploads
pub const CARD_PUBLISH: &str = "card.publish"; pub const CARD_PUBLISH: &str = "card.publish";
// Moderation: edit, delete, publish or unpublish ANYBODY's cards
pub const CARD_EDIT: &str = "card.edit"; pub const CARD_EDIT: &str = "card.edit";
pub const PERMISSION: &str = "permission"; pub const PERMISSION: &str = "permission";
pub const PERMISSION_GRANT: &str = "permission.grant"; pub const PERMISSION_GRANT: &str = "permission.grant";
pub const PERMISSION_REVOKE: &str = "permission.revoke"; pub const PERMISSION_REVOKE: &str = "permission.revoke";
// The whole grantable vocabulary, subtree roots included. Anything not in here pub const ANNOUNCEMENT: &str = "announcement";
// cannot be written to the table pub const ANNOUNCEMENT_MANAGE: &str = "announcement.manage";
// Uploading/publishing your own MVs needs no scope (like custom songs);
// 3dmv.edit is moderation over anybody's
pub const MV: &str = "3dmv";
pub const MV_EDIT: &str = "3dmv.edit";
pub const SCOPES: &[&str] = &[ pub const SCOPES: &[&str] = &[
ALL, ALL,
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE,
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE,
MV, MV_EDIT
]; ];
// Grants live in their own database rather than in userdata.db so that an
// account purge can never take administrative state with it
fn setup_tables(conn: &rusqlite::Connection) { fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch(" conn.execute_batch("
CREATE TABLE IF NOT EXISTS grants ( CREATE TABLE IF NOT EXISTS grants (
@@ -53,18 +49,11 @@ CREATE TABLE IF NOT EXISTS grants (
").unwrap(); ").unwrap();
} }
// The owners are a process-level flag (--owner) rather than table rows: they
// are the bootstrap grantors, so they have to work on a fresh install with an
// empty (or hand-deleted) permissions.db, and they must not be revocable
// through the webui
fn is_owner(user_id: i64) -> bool { fn is_owner(user_id: i64) -> bool {
user_id > 0 && crate::runtime::get_owners().contains(&user_id) user_id > 0 && crate::runtime::get_owners().contains(&user_id)
} }
// Every scope that would satisfy a request for `scope`: "*", each dotted fn has_permission(scope: &str) -> Vec<String> {
// ancestor, and the scope itself. Matching is on whole dot-separated segments,
// so "car" never satisfies "card.upload"
fn implied_by(scope: &str) -> Vec<String> {
if scope == ALL { if scope == ALL {
return vec![String::from(ALL)]; return vec![String::from(ALL)];
} }
@@ -80,7 +69,7 @@ fn implied_by(scope: &str) -> Vec<String> {
rv rv
} }
fn held_scopes(user_id: i64) -> Vec<String> { fn get_permissions(user_id: i64) -> Vec<String> {
let rows = DATABASE.lock_and_select_all("SELECT scope FROM grants WHERE user_id=?1 ORDER BY scope", params!(user_id)).unwrap_or(array![]); let rows = DATABASE.lock_and_select_all("SELECT scope FROM grants WHERE user_id=?1 ORDER BY scope", params!(user_id)).unwrap_or(array![]);
rows.members().map(|scope| scope.to_string()).collect() rows.members().map(|scope| scope.to_string()).collect()
} }
@@ -100,13 +89,11 @@ pub fn has(user_id: i64, scope: &str) -> bool {
if is_owner(user_id) { if is_owner(user_id) {
return true; return true;
} }
let held = held_scopes(user_id); let held = get_permissions(user_id);
implied_by(scope).iter().any(|candidate| held.contains(candidate)) has_permission(scope).iter().any(|candidate| held.contains(candidate))
} }
// Everything this user holds, for the webui to hide what it can't use. An pub fn get_user_permissions(user_id: i64) -> JsonValue {
// owner's implicit "*" is reported here even though it has no row
pub fn scopes_for(user_id: i64) -> JsonValue {
if user_id <= 0 { if user_id <= 0 {
return array![]; return array![];
} }
@@ -114,7 +101,7 @@ pub fn scopes_for(user_id: i64) -> JsonValue {
if is_owner(user_id) { if is_owner(user_id) {
scopes.push(String::from(ALL)); scopes.push(String::from(ALL));
} }
for scope in held_scopes(user_id) { for scope in get_permissions(user_id) {
if !scopes.contains(&scope) { if !scopes.contains(&scope) {
scopes.push(scope); scopes.push(scope);
} }
@@ -148,16 +135,6 @@ pub fn grants() -> JsonValue {
rv rv
} }
// The only way a row is ever written. Two conditions, both required:
//
// 1. the grantor holds permission.grant, and
// 2. the grantor holds the scope being granted
//
// (2) is what makes escalation impossible. has() only ever implies downwards,
// so holding a leaf never satisfies its parent - a user with card.upload can
// hand out card.upload and nothing else, and can no more grant themselves
// "card" (or "*") than they can grant it to anybody else. Both checks read the
// live table, so a revoked grantor loses the ability on their next request
pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> { pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
if user_id <= 0 { if user_id <= 0 {
return Err(String::from("Invalid user id")); return Err(String::from("Invalid user id"));
@@ -175,9 +152,6 @@ pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
Ok(()) Ok(())
} }
// Revoking needs the same "you must hold it yourself" rule as granting, so a
// junior admin can't strip a senior one. An owner has no rows to delete, but
// the check is explicit so it stays true if that ever changes
pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> { pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> {
if user_id <= 0 { if user_id <= 0 {
return Err(String::from("Invalid user id")); return Err(String::from("Invalid user id"));
@@ -198,6 +172,11 @@ pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String>
Ok(()) Ok(())
} }
// rest of file is tests that ai wrote
// I didn't read through them because I don't super care about tests but they probably do something
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -258,7 +237,7 @@ mod tests {
for scope in SCOPES { for scope in SCOPES {
assert!(!has(105, scope), "scope {}", scope); assert!(!has(105, scope), "scope {}", scope);
} }
assert!(scopes_for(105).is_empty()); assert!(get_user_permissions(105).is_empty());
assert!(!has(0, ALL)); assert!(!has(0, ALL));
assert!(!has(-1, ALL)); assert!(!has(-1, ALL));
wipe(105); wipe(105);
@@ -276,7 +255,7 @@ mod tests {
insert(110, CARD_UPLOAD, 0); insert(110, CARD_UPLOAD, 0);
grant(111, CARD_UPLOAD, 110).unwrap(); grant(111, CARD_UPLOAD, 110).unwrap();
grant(111, CARD_UPLOAD, 110).unwrap(); // idempotent grant(111, CARD_UPLOAD, 110).unwrap(); // idempotent
assert_eq!(held_scopes(111), vec![String::from(CARD_UPLOAD)]); assert_eq!(get_permissions(111), vec![String::from(CARD_UPLOAD)]);
assert!(grant(111, CARD_EDIT, 110).is_err()); assert!(grant(111, CARD_EDIT, 110).is_err());
assert!(grant(111, CARD, 110).is_err()); assert!(grant(111, CARD, 110).is_err());
assert!(grant(110, ALL, 110).is_err()); assert!(grant(110, ALL, 110).is_err());
@@ -324,9 +303,9 @@ mod tests {
assert!(has(118, scope), "scope {}", scope); assert!(has(118, scope), "scope {}", scope);
assert!(has(120, scope), "scope {}", scope); assert!(has(120, scope), "scope {}", scope);
} }
assert_eq!(scopes_for(118).len(), 1); assert_eq!(get_user_permissions(118).len(), 1);
assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL)); assert_eq!(get_user_permissions(118)[0].to_string(), String::from(ALL));
assert!(held_scopes(118).is_empty()); assert!(get_permissions(118).is_empty());
// An owner can bootstrap-grant, and can't be revoked // An owner can bootstrap-grant, and can't be revoked
grant(119, ALL, 118).unwrap(); grant(119, ALL, 118).unwrap();
assert!(has(119, ALL)); assert!(has(119, ALL));
@@ -346,7 +325,7 @@ mod tests {
assert!(!scope.is_empty()); assert!(!scope.is_empty());
assert!(!scope.ends_with('.')); assert!(!scope.ends_with('.'));
} }
for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE] { for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT_MANAGE, MV_EDIT] {
assert!(SCOPES.contains(&scope), "scope {}", scope); assert!(SCOPES.contains(&scope), "scope {}", scope);
} }
} }

View File

@@ -31,12 +31,19 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
if args.purge { if args.purge {
println!("Purging accounts..."); println!("Purging accounts...");
// Cabinets first, so the accounts they take with them are gone before
// purge_accounts runs its VACUUM over the same database
let machines = crate::router::arcade::purge_machines();
if machines > 0 {
println!("Purged {} arcade machine(s)", machines);
}
let ct = crate::router::userdata::purge_accounts(); let ct = crate::router::userdata::purge_accounts();
println!("Purged {} accounts", ct); println!("Purged {} accounts", ct);
} }
router::custom_song::migrate::run(); router::custom_song::migrate::run();
router::custom_song::sweep_audio(); router::custom_song::sweep_audio();
router::custom_3dmv::sweep_blobs();
// The multi-live relay's expiry timers, on the system arbiter rather than on whichever // The multi-live relay's expiry timers, on the system arbiter rather than on whichever
// HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not // HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not
// be able to strand every room's seats for the life of the process. // be able to strand every room's seats for the life of the process.

View File

@@ -44,6 +44,18 @@ pub struct Args {
#[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")] #[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")]
pub enable_custom_cards: bool, pub enable_custom_cards: bool,
#[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")]
pub enable_custom_3dmv: bool,
#[arg(long, default_value_t = false, help = "Enable arcade mode (cabinet registration, per-machine guest accounts, LP-free arcade lives). Disabled by default; every /api/arcade endpoint and webui element is hidden unless this is set")]
pub enable_arcade: bool,
#[arg(long, default_value_t = 90, help = "Days an arcade machine may go unseen before --purge deletes it together with its machine and guest accounts")]
pub arcade_machine_ttl: u64,
#[arg(long, default_value_t = 30, help = "Minutes a card-bound account may play LP-free after the cabinet ran /api/arcade/session for its card. Each arcade live restarts the window, up to four windows from the session itself; outside it the account is an ordinary phone account and pays LP")]
pub arcade_session_ttl: u64,
#[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")] #[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")]
pub owner: Vec<i64>, pub owner: Vec<i64>,

View File

@@ -1,4 +1,5 @@
pub mod start; pub mod start;
pub mod arcade;
pub mod global; pub mod global;
pub mod login; pub mod login;
pub mod userdata; pub mod userdata;
@@ -23,6 +24,7 @@ pub mod card;
pub mod shop; pub mod shop;
pub mod custom_song; pub mod custom_song;
pub mod custom_card; pub mod custom_card;
pub mod custom_3dmv;
pub mod rich_text; pub mod rich_text;
pub mod webui; pub mod webui;
pub mod clear_rate; pub mod clear_rate;
@@ -222,11 +224,12 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/cheat" => webui::cheat(req, body), "/api/webui/cheat" => webui::cheat(req, body),
"/api/webui/grantPermission" => webui::grant_permission(req, body), "/api/webui/grantPermission" => webui::grant_permission(req, body),
"/api/webui/revokePermission" => webui::revoke_permission(req, body), "/api/webui/revokePermission" => webui::revoke_permission(req, body),
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
"/api/webui/bindArcadeCard" => webui::bind_arcade_card(req, body),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
} else { } else {
match req.path() { match req.path() {
"/web/announcement" => web::announcement(req),
"/api/webui/userInfo" => webui::user(req), "/api/webui/userInfo" => webui::user(req),
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await, "/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
"/webui/logout" => webui::logout(req), "/webui/logout" => webui::logout(req),
@@ -240,7 +243,9 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/listCharacters" => webui::list_characters(req), "/api/webui/listCharacters" => webui::list_characters(req),
"/api/webui/listSkillCenters" => webui::list_skill_centers(req), "/api/webui/listSkillCenters" => webui::list_skill_centers(req),
"/api/webui/customCardLimits" => webui::custom_card_limits(req), "/api/webui/customCardLimits" => webui::custom_card_limits(req),
"/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req),
"/api/webui/myScopes" => webui::my_scopes(req), "/api/webui/myScopes" => webui::my_scopes(req),
"/api/webui/listArcadeMachines" => webui::list_arcade_machines(req),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
} }
@@ -260,10 +265,12 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.route(actix_web::web::get().to(home::gift_get)) .route(actix_web::web::get().to(home::gift_get))
.route(actix_web::web::post().to(user::gift)) .route(actix_web::web::post().to(user::gift))
) )
.configure(arcade::routes)
.configure(card::routes) .configure(card::routes)
.configure(chat::routes) .configure(chat::routes)
.configure(custom_song::routes) .configure(custom_song::routes)
.configure(custom_card::routes) .configure(custom_card::routes)
.configure(custom_3dmv::routes)
.configure(debug::routes) .configure(debug::routes)
.configure(event::routes) .configure(event::routes)
.configure(exchange::routes) .configure(exchange::routes)
@@ -292,4 +299,6 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
); );
cfg.configure(custom_song::web_routes); cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::web_routes); cfg.configure(custom_card::web_routes);
cfg.configure(custom_3dmv::web_routes);
cfg.configure(web::routes);
} }

928
src/router/arcade.rs Normal file
View File

@@ -0,0 +1,928 @@
// 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.
//
// 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};
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")
.route("/info", web::get().to(info))
.route("/register", web::post().to(register))
.route("/session", web::post().to(session))
.route("/bind", web::post().to(bind))
);
}
pub fn disabled() -> bool {
let args = crate::get_args();
args.hidden || !args.enable_arcade
}
// Days a machine may go unseen before --purge deletes it. 0 means never.
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> {
let card_id = body["card_id"].as_str().unwrap_or("").trim().to_string();
if card_id.is_empty() {
return None;
}
if card_id.len() > MAX_CARD_ID_LEN || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
Some(card_id)
}
// 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());
}
// An account made for a card nobody has named yet takes the card's last four
// digits, the way a cabinet prints them on a receipt. The on-cabinet name entry
// overwrites it.
fn card_account_name(card_id: &str) -> String {
let start = card_id.len().saturating_sub(4);
card_id[start..].to_string()
}
// The account this card was issued seconds ago, and which nothing has happened
// to since - the only account /api/arcade/session will ever rename.
//
// The cabinet's name entry is a second /session for the same card: the first one
// created the account and answered is_new, the player typed a name, and this is
// the same call again carrying it. By then the card is KNOWN, so the rename has
// to be told apart from an ordinary card session, and it has to be told apart
// hard: a rename that reached a real account would let anyone holding a card id
// retitle a stranger's save. Every clause below has to hold.
//
// Deliberately not a `renamed` column: the account itself carries the proof.
fn issued_by_this_credit(card: &str, user_id: i64, now: i64, ttl: i64) -> bool {
// The credit that created it is still running, at this card. live_card_session
// is the window /api/arcade/session opened and nothing else ever opens
// (database/arcade.rs:240), so this is "the same credit, still going". `ttl`
// is session_ttl() in production and the test's own number in the test - the
// shape purge_machines_before and retire_user_at already use here.
let Some((open_card, opened)) = database::live_card_session(user_id, now) else {
return false;
};
if open_card != card || now - opened > ttl {
return false;
}
// A cabinet's own machine or guest identity is never a card's account
if database::machine_of_account(user_id).is_some() {
return false;
}
// A phone has owned it - the same test bind_card's cleanup trusts
if userdata::has_transfer_password(user_id) {
return false;
}
let user = userdata::get_acc_from_uid(user_id);
if user["error"].as_bool().unwrap_or(false) {
return false;
}
// It still carries the placeholder /session gave it, and nothing has been
// played on it. Either alone would be enough to make a rename harmless; both
// together make it provable.
if user["user"]["name"].as_str().unwrap_or("") != card_account_name(card) {
return false;
}
user["live_list"].is_empty() && user["live_mission_list"].is_empty()
}
// The one write a rename is. /api/user does exactly this for a phone player
// (user.rs:138-140); there is no other name in an account.
fn rename_account(user_id: i64, login_token: &str, name: &str) {
let mut user = userdata::get_acc_from_uid(user_id);
user["user"]["name"] = name.into();
userdata::save_acc(login_token, user);
}
// -- endpoints --------------------------------------------------------------
// The client asks this before it offers to convert a device: a server without
// the module answers None and the Title-menu entry refuses.
async fn info() -> impl Responder {
if disabled() {
return Api(None);
}
Api(Some(object!{
"enabled": true,
"machine_ttl_days": machine_ttl_days()
}))
}
// 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);
}
let name = userdata::starter::clean_name(body["name"].as_str().unwrap_or(""), DEFAULT_MACHINE_NAME);
let Some((machine_user_id, machine_uuid)) = userdata::starter::create(&name) else {
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);
};
let machine_id = database::generate_machine_id();
database::insert_machine(&machine_id, &name, machine_user_id, guest_user_id);
println!("arcade: registered machine {} \"{}\" (machine {}, guest {})", machine_id, name, machine_user_id, guest_user_id);
Api(Some(object!{
"machine_id": machine_id,
"machine_user_id": machine_user_id,
"machine_uuid": machine_uuid,
"guest_user_id": guest_user_id,
"guest_uuid": guest_uuid
}))
}
// One credit. Answers the account the player is about to become: the cabinet's
// guest (reset in place) or, with a card, the account that card names.
//
// `name` is the cabinet's on-screen name entry, and it is read in exactly two
// places: the account a brand new card is issued, and a rename of that same
// account while the credit that created it is still running (see
// issued_by_this_credit). A guest credit ignores it - a guest is named after its
// cabinet - and so does an ordinary card session.
async fn session(Body(body): Body) -> impl Responder {
if disabled() {
return Api(None);
}
let machine_id = body["machine_id"].as_str().unwrap_or("").to_string();
let Some(machine) = database::get_machine(&machine_id) else {
println!("arcade: session for unknown machine \"{}\"", machine_id);
return Api(None);
};
database::touch_machine(&machine_id);
let machine_name = machine["name"].as_str().unwrap_or(DEFAULT_MACHINE_NAME).to_string();
// A guest credit is a session with no card_id at all (every credit in v1) or
// an empty one. Anything else is a card being presented, and a card id that
// does not parse is refused rather than quietly played as a guest on
// somebody's credit.
let presented = !body["card_id"].is_null() && !body["card_id"].as_str().unwrap_or("").trim().is_empty();
let card = card_id(&body);
if presented && card.is_none() {
println!("arcade: machine {} presented an unusable card id", machine_id);
return Api(None);
}
let typed_name = body["name"].as_str().unwrap_or("").trim().to_string();
let Some(card) = card else {
// The cabinet's own guest, rewritten from scratch. Same user id, so the
// 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);
return Api(None);
};
return Api(Some(object!{
"user_id": guest_user_id,
"uuid": uuid,
"name": machine_name,
"is_new": true
}));
};
// A known card plays its own account, with every clear it has ever earned
if let Some(user_id) = database::card_user(&card) {
let uuid = userdata::get_login_token(user_id);
if !uuid.is_empty() {
// The cabinet's name entry, coming back for the account this credit
// just made. Checked BEFORE the window is re-opened, because the
// proof is the window the previous call opened.
if !typed_name.is_empty() && issued_by_this_credit(&card, user_id, global::timestamp() as i64, session_ttl()) {
let named = userdata::starter::clean_name(&typed_name, &card_account_name(&card));
rename_account(user_id, &uuid, &named);
println!("arcade: machine {} named its new card's account {} \"{}\"", machine_id, user_id, named);
}
open_card_session(&card, &machine_id);
let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string();
return Api(Some(object!{
"user_id": user_id,
"uuid": uuid,
"name": name,
"is_new": false
}));
}
// The mapping outlived its account (deleted through the webui, or aged
// out with a machine). The card is not broken: it gets a new account.
println!("arcade: card mapping pointed at missing account {} - reissuing", user_id);
}
// A card nobody has seen before. It gets an account named after its own last
// four digits unless the cabinet already has a name for it - which it only
// does when something other than the entrance's own two-step asked for one.
let name = userdata::starter::clean_name(&typed_name, &card_account_name(&card));
let Some((user_id, uuid)) = userdata::starter::create(&name) else {
return Api(None);
};
database::set_card(&card, user_id);
open_card_session(&card, &machine_id);
println!("arcade: machine {} issued account {} to a new card", machine_id, user_id);
Api(Some(object!{
"user_id": user_id,
"uuid": uuid,
"name": name,
"is_new": true
}))
}
// Point a card at an existing (phone) account. The proof is the data-transfer
// code and its password - the same check /api/user/gglverifymigrationcode makes
// (user.rs:214-220), through the one function that owns that comparison.
//
// Shared by the cabinet's own /api/arcade/bind and the webui account page's
// form: the browser cannot speak the game protocol (encrypted bodies behind the
// asset gate), so it gets its own entrance, not its own copy of the rule.
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 Some(card) = card_id(&object!{ "card_id": card }) else {
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
// 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. The
// proof this bind takes - a transfer code and password - is exactly what a
// hostile client can register on a guest during its own credit.
if database::machine_of_account(user_id).is_some() {
return Err(String::from("That account belongs to an arcade cabinet"));
}
let previous = database::card_user(&card);
database::set_card(&card, user_id);
// A card that was carrying a throwaway account the cabinet made for it takes
// that account with it. Anything the player might care about keeps the card
// from taking it: see orphan_of_a_rebound_card.
if let Some(previous) = previous
&& previous != user_id
&& orphan_of_a_rebound_card(previous) {
println!("arcade: card {} left empty account {} behind - removing", card, previous);
userdata::delete_account(previous);
}
println!("arcade: card {} now plays as account {}", card, user_id);
Ok(user_id)
}
async fn bind(Body(body): Body) -> impl Responder {
match bind_card(
body["card_id"].as_str().unwrap_or(""),
&body["migrationCode"].to_string(),
&body["pass"].to_string()
) {
Ok(user_id) => Api(Some(object!{ "user_id": user_id })),
Err(reason) => {
println!("arcade: bind refused - {}", reason);
Api(None)
}
}
}
// An account /api/arcade/session issued to an unknown card that was then never
// used for anything. Every one of these has to hold for it to be thrown away
// with the card, because a false positive deletes somebody's save:
// * no other card still points at it,
// * it is not a cabinet's own machine or guest identity,
// * it has never registered a transfer password, so no phone ever owned it,
// * and it has no live record at all - nothing was ever played on it.
fn orphan_of_a_rebound_card(user_id: i64) -> bool {
if database::account_has_card(user_id) {
return false;
}
if database::machine_of_account(user_id).is_some() {
return false;
}
if userdata::has_transfer_password(user_id) {
return false;
}
let user = userdata::get_acc_from_uid(user_id);
if user["error"].as_bool().unwrap_or(false) {
return false;
}
user["live_list"].is_empty() && user["live_mission_list"].is_empty()
}
// -- 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 {
return None;
}
if database::machine_of_account(user_id).is_some() {
return Some(user_id);
}
database::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;
}
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 {
println!("arcade: account {} ended an arcade live that was never started", user_id);
return None;
};
if !flag(&started["arcade"]) {
println!("arcade: account {} ended a live it started as an ordinary play", user_id);
return None;
}
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)
}
// 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) {
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;
for (index, key) in ["scoreC", "scoreB", "scoreA", "scoreS"].iter().enumerate() {
if let Some(threshold) = live[*key].as_i64()
&& threshold > 0
&& score >= threshold {
rank = index as i64 + 1;
}
}
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;
}
Some(user_id)
}
fn retire_user_at(login_token: &str, body: &JsonValue, now: i64) -> Option<i64> {
let started = live::get_started_live(login_token, body)?;
if !flag(&started["arcade"]) {
return None;
}
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);
let level = body["level"].as_i64().unwrap_or(0);
let score = body["live_score"]["score"].as_i64().unwrap_or(0);
database::insert_play(&machine_id, user_id, live_id, level, score, score_rank(live_id, score), cleared);
database::touch_machine(&machine_id);
}
// -- 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"
if ttl == 0 {
return 0;
}
purge_machines_before(global::timestamp() as i64 - (ttl as i64 * 24 * 60 * 60))
}
fn purge_machines_before(cutoff: i64) -> usize {
let dead = database::machines_last_seen_before(cutoff);
for machine in dead.members() {
let machine_id = machine["machine_id"].as_str().unwrap_or("");
println!(
"Removing arcade machine {} (last seen {})",
machine_id,
global::format_datetime(machine["last_seen"].as_u64().unwrap_or(0))
);
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) {
userdata::delete_account(user_id);
}
}
database::delete_machine(machine_id);
}
dead.len()
}
// -- 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![];
}
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"));
}
let Some(machine) = database::get_machine(machine_id) else {
return Err(format!("No arcade machine {}", machine_id));
};
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) {
userdata::delete_account(user_id);
}
}
database::delete_machine(machine_id);
println!("arcade: machine {} removed through the webui", machine_id);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
// The flag the client sends as 0/1 and the flag it might send as a bool are
// the same flag; nothing else is
#[test]
fn the_arcade_flag_reads_both_shapes() {
assert!(flag(&true.into()));
assert!(flag(&(1).into()));
assert!(!flag(&false.into()));
assert!(!flag(&(0).into()));
assert!(!flag(&JsonValue::Null));
assert!(!flag(&"yes".into()));
}
// A card id is refused rather than repaired
#[test]
fn card_ids_are_validated_not_sanitised() {
assert_eq!(card_id(&object!{ card_id: "0123456789012345" }).as_deref(), Some("0123456789012345"));
assert_eq!(card_id(&object!{ card_id: " 0123456789012345 " }).as_deref(), Some("0123456789012345"));
assert!(card_id(&object!{}).is_none());
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 account a fresh card gets is named after the card
#[test]
fn a_new_card_is_named_after_its_last_four_digits() {
assert_eq!(card_account_name("0123456789012345"), "2345");
assert_eq!(card_account_name("12"), "12");
}
// The rank stored in the ledger is the one the result screen shows, read off
// the live's own masterdata thresholds (live.csv 1100101: C 20000, B 100000,
// A 250000, S 350000)
#[test]
fn the_ledger_rank_comes_from_the_lives_own_thresholds() {
assert_eq!(score_rank(1100101, 0), 0);
assert_eq!(score_rank(1100101, 19_999), 0);
assert_eq!(score_rank(1100101, 20_000), 1);
assert_eq!(score_rank(1100101, 100_000), 2);
assert_eq!(score_rank(1100101, 250_000), 3);
assert_eq!(score_rank(1100101, 350_000), 4);
assert_eq!(score_rank(1100101, 9_999_999), 4);
// A custom song has no official row, so it has no rank
assert_eq!(score_rank(10_000, 9_999_999), 0);
}
// The card-rebind cleanup only ever takes an account that is provably a
// throwaway: a real player's account survives losing its card
#[test]
fn only_an_untouched_throwaway_follows_its_card() {
let _lock = crate::runtime::lock_test_data_path();
// Never played, no card, no password, not a cabinet: a throwaway
let (orphan, _) = userdata::starter::create("2345").unwrap();
assert!(orphan_of_a_rebound_card(orphan));
// Somebody played on it
let (played, played_token) = userdata::starter::create("2346").unwrap();
let mut user = userdata::get_acc_from_uid(played);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
userdata::save_acc(&played_token, user);
assert!(!orphan_of_a_rebound_card(played));
// Somebody can take it over from a phone
let (phone, _) = userdata::starter::create("2347").unwrap();
userdata::user::migration::save_acc_transfer(phone, "hunter2");
assert!(!orphan_of_a_rebound_card(phone));
// Another card still points at it
let (shared, _) = userdata::starter::create("2348").unwrap();
crate::database::arcade::set_card("9999888877776666", shared);
assert!(!orphan_of_a_rebound_card(shared));
// It is a cabinet's own identity
let (machine_account, _) = userdata::starter::create("Cabinet").unwrap();
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
let machine_id = crate::database::arcade::generate_machine_id();
crate::database::arcade::insert_machine(&machine_id, "Cabinet", machine_account, guest_account);
assert!(!orphan_of_a_rebound_card(machine_account));
assert!(!orphan_of_a_rebound_card(guest_account));
// An account that no longer exists is not deleted twice
userdata::delete_account(orphan);
assert!(!orphan_of_a_rebound_card(orphan));
crate::database::arcade::delete_machine(&machine_id);
}
// 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
// proves it was a cabinet's - and it lands in the ledger as a failed song
// rather than not at all.
#[test]
fn a_failed_cabinet_song_lands_in_the_ledger_uncleared() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
let (machine_account, _) = userdata::starter::create("Cabinet Retire").unwrap();
let (guest_account, guest_token) = userdata::starter::create(GUEST_NAME).unwrap();
let machine_id = db::generate_machine_id();
db::insert_machine(&machine_id, "Cabinet Retire", machine_account, guest_account);
// The credit's song: /live/start carried the flag, so the record does
let start = object!{
master_live_id: 1100101,
level: 4,
deck_slot: 1,
live_boost: 1,
arcade: true
};
live::start_live(&guest_token, &start);
// The life gauge emptied: the client played it out and reported the score
// it reached at /live/retire, which has no arcade flag of its own
let retire = object!{
master_live_id: 1100101,
level: 4,
live_score: { score: 123_456, max_combo: 40, play_time: 93 }
};
let now = global::timestamp() as i64;
let user_id = retire_user_at(&guest_token, &retire, now).expect("a cabinet's retire was not recognised");
assert_eq!(user_id, guest_account);
record_play(user_id, &retire, false);
let ledger = db::plays_of_machine(&machine_id);
assert_eq!(ledger.len(), 1);
assert_eq!(ledger[0]["cleared"].as_bool(), Some(false));
assert_eq!(ledger[0]["user_id"].as_i64(), Some(guest_account));
assert_eq!(ledger[0]["live_id"].as_i64(), Some(1100101));
assert_eq!(ledger[0]["level"].as_i64(), Some(4));
assert_eq!(ledger[0]["score"].as_i64(), Some(123_456), "the retire's own score was not carried");
// It counts as a song of the credit, and only the cleared count excludes it
assert!(db::list_machines().members().any(|m|
m["machine_id"] == machine_id.as_str() && m["play_count"] == 1 && m["cleared_count"] == 0
));
// A live that was started as an ordinary play is not a cabinet's song,
// however the retire that ends it looks
live::start_live(&guest_token, &object!{ master_live_id: 1100102, level: 4, deck_slot: 1 });
assert!(retire_user_at(&guest_token, &object!{
master_live_id: 1100102,
level: 4,
live_score: { score: 1, max_combo: 1, play_time: 93 }
}, now).is_none(), "an unflagged start was bookkept as a cabinet's song");
// Neither is a retire with no start behind it at all
assert!(retire_user_at(&guest_token, &object!{
master_live_id: 1100103,
level: 4,
live_score: { score: 1, max_combo: 1, play_time: 93 }
}, now).is_none());
db::delete_machine(&machine_id);
userdata::delete_account(machine_account);
userdata::delete_account(guest_account);
}
// A cabinet that aged out takes its two accounts with it - and nothing
// else. A cabinet still in use, and any account a player bound a card to,
// survives the sweep.
#[test]
fn an_aged_out_cabinet_takes_only_its_own_accounts() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
// Old: unseen since before the cutoff
let (old_machine, old_machine_token) = userdata::starter::create("Old cabinet").unwrap();
let (old_guest, old_guest_token) = userdata::starter::create(GUEST_NAME).unwrap();
let old_id = db::generate_machine_id();
db::insert_machine(&old_id, "Old cabinet", old_machine, old_guest);
// Live: seen just now
let (live_machine, live_machine_token) = userdata::starter::create("Live cabinet").unwrap();
let (live_guest, _) = userdata::starter::create(GUEST_NAME).unwrap();
let live_id = db::generate_machine_id();
db::insert_machine(&live_id, "Live cabinet", live_machine, live_guest);
// Old too, but somebody bound a card to its machine account
let (claimed_machine, claimed_machine_token) = userdata::starter::create("Claimed cabinet").unwrap();
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);
// 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);
// Everything registered above is "seen now"; only the two we push back
// are older than the cutoff
let now = global::timestamp() as i64;
for id in [&old_id, &claimed_id] {
db::backdate_machine_for_test(id, now - 1000);
}
assert_eq!(purge_machines_before(now - 500), 2);
// The aged-out cabinet is gone, accounts and all
assert!(db::get_machine(&old_id).is_none());
assert_eq!(userdata::uid_from_login_token(&old_machine_token), 0);
assert_eq!(userdata::uid_from_login_token(&old_guest_token), 0);
// The cabinet still in use is untouched
assert!(db::get_machine(&live_id).is_some());
assert_eq!(userdata::uid_from_login_token(&live_machine_token), live_machine);
// 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));
// A card-bound player account is never a candidate in the first place
assert_eq!(userdata::uid_from_login_token(&player_token), player);
db::delete_machine(&live_id);
}
// A card the server has never seen gets an account named after its own last
// four digits, and the name the player then types at the cabinet lands on
// that account - and on nothing else. The rename is a second /session for
// the same card, so every other account has to be immune to it.
#[test]
fn a_new_cards_account_takes_the_name_the_player_types() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
const TTL: i64 = 30 * 60;
let now = global::timestamp() as i64;
let card = "6060606060602345";
let machine_id = db::generate_machine_id();
// /api/arcade/session, first call: an unknown card, so the account is
// created under the card's last four digits and the credit's window opens
let name = card_account_name(card);
assert_eq!(name, "2345");
let (user_id, token) = userdata::starter::create(&name).unwrap();
db::set_card(card, user_id);
db::open_card_session(card, &machine_id, now + TTL);
// ...and the same call again, carrying the name the player typed
assert!(issued_by_this_credit(card, user_id, now, TTL));
rename_account(user_id, &token, "Ethan");
assert_eq!(userdata::get_name_and_rank(user_id)["user_name"].as_str(), Some("Ethan"));
// Once named it is somebody's account: a second name entry on the same
// card cannot touch it
assert!(!issued_by_this_credit(card, user_id, now, TTL), "a named account was still renameable");
// Neither can one whose credit has ended - the window is the proof
let later_card = "6060606060601111";
let (later, _) = userdata::starter::create(&card_account_name(later_card)).unwrap();
db::set_card(later_card, later);
db::open_card_session(later_card, &machine_id, now + TTL);
db::backdate_card_session_for_test(later_card, now - TTL - 1, now + TTL);
assert!(!issued_by_this_credit(later_card, later, now, TTL), "an old credit could still rename");
db::backdate_card_session_for_test(later_card, now, now - 1);
assert!(!issued_by_this_credit(later_card, later, now, TTL), "a closed window could still rename");
// Nor a card presented at one cabinet renaming through another card's row
db::open_card_session(later_card, &machine_id, now + TTL);
assert!(!issued_by_this_credit(card, later, now, TTL), "a different card's id could rename");
// A phone account behind a card is never renameable, however fresh its
// window is: it has a transfer password, and its name is its own
let phone_card = "6060606060602222";
let (phone, _) = userdata::starter::create(&card_account_name(phone_card)).unwrap();
userdata::user::migration::save_acc_transfer(phone, "hunter2");
db::set_card(phone_card, phone);
db::open_card_session(phone_card, &machine_id, now + TTL);
assert!(!issued_by_this_credit(phone_card, phone, now, TTL), "a phone account was renameable");
// And neither is an account that has already played something
let played_card = "6060606060603333";
let (played, played_token) = userdata::starter::create(&card_account_name(played_card)).unwrap();
db::set_card(played_card, played);
db::open_card_session(played_card, &machine_id, now + TTL);
assert!(issued_by_this_credit(played_card, played, now, TTL));
let mut user = userdata::get_acc_from_uid(played);
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 1, high_score: 1, max_combo: 1 }).unwrap();
userdata::save_acc(&played_token, user);
assert!(!issued_by_this_credit(played_card, played, now, TTL), "a played account was renameable");
// Finally: a cabinet's own identity, which a card may not name at all
let (machine_account, _) = userdata::starter::create("Cabinet Name").unwrap();
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
db::insert_machine(&machine_id, "Cabinet Name", machine_account, guest_account);
let cabinet_card = "6060606060604444";
db::set_card(cabinet_card, guest_account);
db::open_card_session(cabinet_card, &machine_id, now + TTL);
assert!(!issued_by_this_credit(cabinet_card, guest_account, now, TTL), "a cabinet identity was renameable");
db::delete_machine(&machine_id);
for id in [user_id, later, phone, played, machine_account, guest_account] {
userdata::delete_account(id);
}
}
}

1504
src/router/custom_3dmv.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
use std::collections::HashMap;
use std::fs;
use std::io::{Cursor, Read, Seek, Write};
use zip::write::SimpleFileOptions;
use super::{blob_path, field_key};
use crate::database::custom_3dmv as database;
// Export packages carry the stored blobs byte-for-byte (they ARE the original
// uploads - nothing is transcoded) plus the upload metadata, so an MV can be
// re-uploaded on any ew server. Layout of the zip:
// manifest.json {format, name, name_en, music_id, member_count}
// model_{slot} / motion_{slot} / facial_{slot} / camera / config / stage
// published is a per-server setting and deliberately not part of the package.
pub fn build(mv_id: i64) -> Result<Vec<u8>, String> {
let mv = database::get_mv(mv_id).ok_or(String::from("MV not found"))?;
let manifest = jzon::object!{
"format": 1,
"name": mv["name"].clone(),
"name_en": mv["name_en"].clone(),
"music_id": mv["music_id"].clone(),
"member_count": mv["member_count"].clone()
};
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = SimpleFileOptions::default();
let mut add = |name: &str, bytes: &[u8]| -> Result<(), String> {
zip.start_file(name, options).map_err(|e| e.to_string())?;
zip.write_all(bytes).map_err(|e| e.to_string())
};
add("manifest.json", jzon::stringify(manifest).as_bytes())?;
for file in mv["files"].members() {
let Some(name) = field_key(file) else { continue; };
let md5 = file["md5"].as_str().unwrap_or("");
let bytes = fs::read(blob_path(md5)).map_err(|e| e.to_string())?;
add(&name, &bytes)?;
}
Ok(zip.finish().map_err(|e| e.to_string())?.into_inner())
}
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
let mut file = archive.by_name(name).ok()?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).ok()?;
Some(bytes)
}
// Expands a package into the same field map the upload form produces. The
// package's metadata wins over form fields - except music_id, which is a
// server-local id: a form-supplied song wins, and the manifest's only fills
// in when the form left it blank (same-server re-upload)
pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(), String> {
let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?;
let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?;
let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?;
if manifest["format"].as_i64() != Some(1) {
return Err(String::from("Unsupported package format"));
}
for key in ["name", "name_en", "member_count"] {
if !manifest[key].is_null() {
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
}
}
if !fields.get("music_id").is_some_and(|v| !v.is_empty()) && !manifest["music_id"].is_null() {
fields.insert(String::from("music_id"), manifest["music_id"].to_string().into_bytes());
}
let member_count = manifest["member_count"].as_i64().unwrap_or(0);
for slot in 1..=member_count.clamp(0, super::MAX_MEMBER_COUNT) {
for role in ["model", "motion", "facial"] {
if let Some(bytes) = read_entry(&mut archive, &format!("{}_{}", role, slot)) {
fields.insert(format!("{}_{}", role, slot), bytes);
}
}
}
for name in ["camera", "config", "stage"] {
if let Some(bytes) = read_entry(&mut archive, name) {
fields.insert(String::from(name), bytes);
}
}
Ok(())
}

View File

@@ -0,0 +1,121 @@
// Structural validation of a VMD (Vocaloid Motion Data) upload: the header
// magic plus a full section walk with bounds checks, so a truncated or
// corrupt file is rejected cheaply at upload time instead of crashing a
// client mid-live. Nothing here decodes the animation - the stored bytes are
// served verbatim and the client owns the semantics.
//
// Layout (little-endian): 30-byte magic "Vocaloid Motion Data 0002"
// (NUL-padded), 20-byte model name, then up to 6 sections, each a uint32
// count followed by fixed-size records - bone (111), morph (23), camera (61),
// light (28), self-shadow (9) - and the property/IK section whose records are
// variable-sized (9 bytes + 21 per IK entry). Camera-only and motion-only
// files legitimately end early: EOF on a section boundary reads as count 0.
const MAGIC_V2: &[u8] = b"Vocaloid Motion Data 0002";
const MAGIC_V1: &[u8] = b"Vocaloid Motion Data file";
const HEADER_LEN: usize = 30 + 20;
// (record size, section name) for the fixed-size sections, in file order
const FIXED_SECTIONS: &[(usize, &str)] = &[
(111, "bone"),
(23, "morph"),
(61, "camera"),
(28, "light"),
(9, "self-shadow")
];
fn read_count(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
Some(u32::from_le_bytes(bytes.get(offset..end)?.try_into().unwrap()))
}
pub fn validate(label: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() < HEADER_LEN {
return Err(format!("'{}' is too short to be a VMD file", label));
}
if bytes.starts_with(MAGIC_V1) {
return Err(format!("'{}' is a version 1 VMD (\"Vocaloid Motion Data file\") - re-save it as version 2 in MMD", label));
}
if !bytes.starts_with(MAGIC_V2) {
return Err(format!("'{}' is not a VMD file (missing the \"Vocaloid Motion Data 0002\" header)", label));
}
let mut offset = HEADER_LEN;
for (record_size, section) in FIXED_SECTIONS {
// EOF exactly on a section boundary: the remaining sections are absent
if offset == bytes.len() {
return Ok(());
}
let Some(count) = read_count(bytes, offset) else {
return Err(format!("'{}' is truncated in the {} section header", label, section));
};
offset += 4;
let section_len = (count as usize).checked_mul(*record_size)
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
.ok_or(format!("'{}' is truncated: the {} section claims {} records past the end of the file", label, section, count))?;
offset += section_len;
}
// Property/IK section: uint32 frame, byte visible, uint32 ikCount, then
// ikCount x (20-byte bone name + 1-byte enabled)
if offset == bytes.len() {
return Ok(());
}
let Some(count) = read_count(bytes, offset) else {
return Err(format!("'{}' is truncated in the property section header", label));
};
offset += 4;
for _ in 0..count {
let Some(ik_count) = read_count(bytes, offset + 5) else {
return Err(format!("'{}' is truncated in the property section", label));
};
let record_len = (ik_count as usize).checked_mul(21)
.and_then(|len| len.checked_add(9))
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
.ok_or(format!("'{}' is truncated in the property section", label))?;
offset += record_len;
}
// Trailing bytes after the last section are tolerated, like MMD does
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structure_walk_accepts_real_shapes_and_rejects_corruption() {
let vmd = crate::router::custom_3dmv::tests::test_vmd(1);
assert!(validate("motion_1", &vmd).is_ok());
// Camera-only file: sections end after camera
let cam = crate::router::custom_3dmv::tests::test_camera_vmd(2);
assert!(validate("camera", &cam).is_ok());
// A bare header with every section absent is structurally fine
let mut bare = Vec::new();
bare.extend(MAGIC_V2);
bare.resize(HEADER_LEN, 0);
assert!(validate("motion_1", &bare).is_ok());
// Truncations and lies
assert!(validate("motion_1", &vmd[..vmd.len() - 1]).unwrap_err().contains("truncated"));
assert!(validate("motion_1", &vmd[..HEADER_LEN + 2]).unwrap_err().contains("truncated"));
let mut liar = vmd.clone();
liar[HEADER_LEN] = 200; // bone count far past EOF
assert!(validate("motion_1", &liar).unwrap_err().contains("claims 200 records"));
// A count that would overflow the length math
let mut overflow = bare.clone();
overflow.extend(u32::MAX.to_le_bytes());
assert!(validate("motion_1", &overflow).unwrap_err().contains("truncated"));
// Wrong or old magic
assert!(validate("motion_1", b"garbage").unwrap_err().contains("too short"));
let mut wrong = vmd.clone();
wrong[0] = b'X';
assert!(validate("motion_1", &wrong).unwrap_err().contains("not a VMD"));
let mut v1 = vmd.clone();
v1[..MAGIC_V1.len()].copy_from_slice(MAGIC_V1);
assert!(validate("motion_1", &v1).unwrap_err().contains("version 1"));
}
}

View File

@@ -152,6 +152,18 @@ pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
database::non_public_music_ids_for(uid) database::non_public_music_ids_for(uid)
} }
// Whether `uid` may attach cross-feature content (a custom 3D MV) to this
// song: it must exist, and be theirs or publicly visible. Mirrors
// custom_card::validate_character_ref
pub fn can_reference_song(uid: i64, music_id: i64) -> Result<(), String> {
if !disabled()
&& database::get_song_owner(music_id).is_some()
&& (database::get_song_owner(music_id) == Some(uid) || database::song_publicly_visible(music_id)) {
return Ok(());
}
Err(format!("Unknown music_id '{}'", music_id))
}
fn song_path(music_id: i64, file: &str) -> String { fn song_path(music_id: i64, file: &str) -> String {
get_data_path(&format!("custom_songs/{}/{}", music_id, file)) get_data_path(&format!("custom_songs/{}/{}", music_id, file))
} }
@@ -1135,6 +1147,8 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
// Global clear-rate stats for the dead live id (per-user score records are // Global clear-rate stats for the dead live id (per-user score records are
// wiped lazily on each user's next userdata pull) // wiped lazily on each user's next userdata pull)
crate::router::clear_rate::purge_live(music_id); crate::router::clear_rate::purge_live(music_id);
// A custom 3D MV can't outlive the song it plays over
crate::router::custom_3dmv::purge_song(music_id);
let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id))); let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id)));
// Audio is content-addressed and may be shared with another upload // Audio is content-addressed and may be shared with another upload

View File

@@ -40,10 +40,15 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
AssetVersion { region: "JP", platform: "Android", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "ed8f4b8df9d3935d689e1236a6816b2b", latest: false }, AssetVersion { region: "JP", platform: "Android", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "ed8f4b8df9d3935d689e1236a6816b2b", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "407a8fd81cc5f525ad12c957dbefccb2", latest: false }, AssetVersion { region: "JP", platform: "iOS", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "407a8fd81cc5f525ad12c957dbefccb2", latest: false },
// Re-written client versions 2.4.0 - // Re-written client versions 2.4.0 - 2.4.2
AssetVersion { region: "JP", platform: "Windows", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "73c12d3a4986013afa1eee65469124f7", latest: true }, AssetVersion { region: "JP", platform: "Windows", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "d1b4db937c1af8364d38f08296cbcfae", latest: false },
AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "7d98640952f358554306658c2522d7f6", latest: true }, AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "8fcb61a08e69d854438c4f318d38cabd", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "7d7c80f2a8e45789c1f391879710bdbc", latest: true }, AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "65bb39a0030620d9720edb5e65d31ac3", latest: false },
// Re-written client versions 2.5.0 -
AssetVersion { region: "JP", platform: "Windows", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "b8d0e7edcb63f5bdd28817a597772ca6", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: true },
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true }, //AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
]; ];

View File

@@ -194,14 +194,34 @@ async fn payment_ticket(req: HttpRequest) -> impl Responder {
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder { async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
let body = jzon::parse(&body).unwrap(); let body = jzon::parse(&body).unwrap();
let migration_code = body["migration_code"].to_string();
let password = decrypt_transfer_password(&body["migration_password"].to_string()); let password = decrypt_transfer_password(&body["migration_password"].to_string());
let user = userdata::user::migration::get_acc_transfer(&body["migration_code"].to_string(), &password); let user = userdata::user::migration::get_acc_transfer(&migration_code, &password);
let resp = if !user["success"].as_bool().unwrap() || user["user_id"] == 0 { let resp = if !user["success"].as_bool().unwrap() || user["user_id"] == 0 {
// The gree error envelope REQUIRES `code` and `message` on a failure. The old
// {result:"ERR", messsage:"User Not Found"} shape had neither (and misspelt the key),
// which breaks both native gamelibs:
// * iOS: +[GGLError errorWithJson:] builds
// @{NSLocalizedDescriptionKey: message} with message == nil ->
// NSInvalidArgumentException "attempt to insert nil object from objects[0]" -> the app
// hard-crashes the moment a wrong password / unknown code is entered on the new device.
// (It also reads `code` as [nil integerValue] == 0 == SUCCESS, so a non-crashing build
// would have run the success path on an empty payload.)
// * managed: Shock.GGL.Payment.GGLVerifyMigrationCode.CanRetry(code) tests
// code == 7004 (kGGLPErrorVerifyMigrationIncorrectPassword) to show the
// "re-enter your password" dialog instead of the fatal error dialog.
// 7002 = kGGLPErrorVerifyMigrationCodeNotExist, 7004 = incorrect password.
let (code, message) = if userdata::user::migration::transfer_code_exists(&migration_code) {
(7004, "Migration password is incorrect")
} else {
(7002, "Migration code does not exist")
};
object!{ object!{
result: "ERR", result: "NG",
messsage: "User Not Found" code: code,
message: message
} }
} else { } else {
let data_user = userdata::get_acc(&user["login_token"].to_string()); let data_user = userdata::get_acc(&user["login_token"].to_string());

View File

@@ -124,6 +124,10 @@ async fn home(Login(key): Login) -> impl Responder {
} }
user["home"]["unread_chat_count"] = chat_count.into(); user["home"]["unread_chat_count"] = chat_count.into();
let seen_at = user["home"]["announcement_seen_at"].as_i64().unwrap_or(0);
let new_announcement = crate::database::announcements::latest_published_at() > seen_at;
user["home"]["new_announcement_flag"] = (new_announcement as i32).into();
//todo //todo
user["home"]["beginner_mission_complete"] = 1.into(); user["home"]["beginner_mission_complete"] = 1.into();

View File

@@ -3,7 +3,7 @@ use actix_web::{web, HttpRequest, Responder};
use rand::RngExt; use rand::RngExt;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use crate::router::{databases, global, items, userdata, Login, Session, Api}; use crate::router::{arcade, databases, global, items, userdata, Login, Session, Api};
use crate::router::clear_rate::live_completed; use crate::router::clear_rate::live_completed;
use crate::router::tools::guest; use crate::router::tools::guest;
@@ -25,7 +25,17 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
async fn retire(Session { key, body }: Session) -> impl Responder { async fn retire(Session { key, body }: Session) -> impl Responder {
// An arcade song whose life gauge emptied is played out and reported here,
// not at /live/end: there is no cleared flag on the end wire and live_end_ex
// records a clear unconditionally. So this is the only place the cabinet's
// ledger can learn about a failed song, and it has to look before live_retire
// sweeps the start record that proves the song was a cabinet's. Nothing about
// an ordinary retire changes: an unflagged start answers None here.
let arcade_user = arcade::arcade_retire_user(&key, &body);
live_retire(&key, &body); live_retire(&key, &body);
if let Some(user_id) = arcade_user {
arcade::record_play(user_id, &body, false);
}
if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 { if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 {
// Body-derived, so defaulted rather than unwrapped: a retire is not worth a // Body-derived, so defaulted rather than unwrapped: a retire is not worth a
// panicked worker either (live_completed ignores an unknown id/level). // panicked worker either (live_completed ignores an unknown id/level).
@@ -345,6 +355,10 @@ pub fn start_live(login_token: &str, body: &JsonValue) {
} }
async fn start(Session { key, body }: Session) -> impl Responder { async fn start(Session { key, body }: Session) -> impl Responder {
// A live starting on an arcade cabinet is a sighting for that cabinet: its
// last_seen is what the TTL sweeper measures, and a machine in daily use
// must never age out from under its own players.
arcade::live_started(&key, &body);
start_live(&key, &body); start_live(&key, &body);
Api(Some(array![])) Api(Some(array![]))
} }
@@ -840,6 +854,18 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
} }
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder { async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
// "arcade": true - a credit in the cabinet already paid for this play, so LP
// is neither read nor decremented. The seam is the one /multi_live/end uses
// (live_end_ex's consume_lp), with use_lp injected as one normal 1x play, so
// every reward, the EXP, the bonds, the high score and the clear all record
// exactly as they do for a phone player. The flag is only honoured for an
// account that really belongs to a cabinet - see arcade::arcade_play_user.
if let Some(user_id) = arcade::arcade_play_user(&key, &body) {
let body = arcade::live_end_body(&body);
let rv = live_end_ex(&req, &key, &body, false, false, true);
arcade::record_play(user_id, &body, true);
return Api(Some(rv));
}
Api(Some(live_end(&req, &key, &body, false))) Api(Some(live_end(&req, &key, &body, false)))
} }

View File

@@ -62,7 +62,7 @@ fn const_value(id: &str, default: i64) -> i64 {
.unwrap_or(default) .unwrap_or(default)
} }
fn boost_lp(live_boost: i64) -> i64 { pub fn boost_lp(live_boost: i64) -> i64 {
databases::LIVE_BOOST[live_boost.to_string()]["lp"] databases::LIVE_BOOST[live_boost.to_string()]["lp"]
.as_i64() .as_i64()
.unwrap_or(10 * live_boost) .unwrap_or(10 * live_boost)
@@ -289,10 +289,8 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
return Api(None); return Api(None);
} }
// `token` is the room-party correlation key ("{userId}.{guid}") that all up to four // `token` is the room-party correlation key ("{userId}.{guid}") that all up to four
// party members post. Nothing about STARTING a live depends on which room it came // party members post. It is recorded verbatim on the start record and nothing reads
// from — the one thing read out of it is the room's privacy, and that is read here // anything else out of it. Reconciling the party itself — checking the four results
// rather than at /multi_live/end because here the room is certainly still alive (see
// record_room_privacy). Reconciling the party itself — checking the four results
// against each other — is still deferred. // against each other — is still deferred.
// master_event_id is recorded verbatim and never validated here: multi is a permanent // master_event_id is recorded verbatim and never validated here: multi is a permanent
// feature entered against a closed event (see scoring_event), so a closed — or absent // feature entered against a closed event (see scoring_event), so a closed — or absent
@@ -329,7 +327,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
userdata::save_acc(&key, user); userdata::save_acc(&key, user);
body["use_lp"] = consumed.into(); body["use_lp"] = consumed.into();
record_room_privacy(&mut body);
live::start_live(&key, &body); live::start_live(&key, &body);
Api(Some(object!{ Api(Some(object!{
@@ -337,51 +334,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
})) }))
} }
// Whether this live was played in a private (join-by-code) party, which is the one thing
// /multi_live/end reads the room `token` for.
//
// Officially a multi live never updated the score board at all — the client says so on the
// result screen. That stays true for PUBLIC matchmaking; a private party of friends is a
// deliberate divergence and keeps its scores. The room is looked up in the relay's live
// registry, which sits in this same process, so nothing new travels on the wire.
//
// Note the privacy answer is the room's CREATION-time one. The current visible/open flags
// cannot be used: the game hides a public room too, as soon as its members are decided
// (MultiMatchingView.SetRoomOpenVisible(false)), so every room in a live looks unlisted.
fn is_private_party(body: &JsonValue) -> bool {
rooms::token_is_private_room(body["token"].as_str().unwrap_or_default())
}
// The key the answer above is stashed under on the start record.
const RECORDED_PRIVATE: &str = "room_private";
// Asked once, at /multi_live/start, and written onto the payload start_live records.
//
// Asking at /multi_live/end instead made the answer depend on WHEN each of the up to four
// party members got its POST in: the room dies with its last clean leave, so an ender that
// posted after the party broke up saw no room and fell back to "public" while the others
// had already been told "private" — the same live scored differently per player, and a
// private party silently lost its score board. At start time the room is by definition
// alive (its master minted the token moments earlier and every member is still seated), so
// every member records the same flag off the same room.
//
// A registry miss records nothing at all rather than `false`: the end-side lookup is kept
// as the fallback for records written before this existed, and "absent" is what selects it.
fn record_room_privacy(body: &mut JsonValue) {
if let Some(private) = rooms::token_room_privacy(body["token"].as_str().unwrap_or_default()) {
body[RECORDED_PRIVATE] = private.into();
}
}
// What /multi_live/end obeys: the flag recorded at start when there is one, and the live
// registry lookup only for a record that predates it.
fn recorded_privacy(started: Option<&JsonValue>, body: &JsonValue) -> bool {
match started.and_then(|s| s[RECORDED_PRIVATE].as_bool()) {
Some(private) => private,
None => is_private_party(body)
}
}
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder { async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
// Older clients speak an incompatible multi — refuse before touching any state // Older clients speak an incompatible multi — refuse before touching any state
// (in particular before the start record can be consumed). // (in particular before the start record can be consumed).
@@ -458,21 +410,12 @@ async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder
} }
} }
// A public room scores like the official server did: no high score, no score board. // A multi live scores like the official server did: no high score, no score board
// Read off the start record, so every member of one party gets the same answer no // the client's own result screen says so. Private (join-by-code) parties are no
// matter how late its POST lands. Only a record from before that was recorded falls // exception (Ethan 2026-08-12; an earlier build recorded private-party scores, and
// back to a live lookup, where a room the registry no longer knows about (torn down, // the room-privacy plumbing that told the two apart left with that behaviour). The
// or a restart since the live started) is treated as public — the behaviour to be // clear count and max combo still record either way — the live really was played.
// wrong on. let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, false);
let private = recorded_privacy(started, &body);
println!(
"multi_live/end: uid {} room is {} — score board {}",
uid,
if private { "PRIVATE" } else { "PUBLIC/unknown" },
if private { "updated" } else { "skipped (official)" }
);
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, private);
rv["is_penalty_miss_ratio"] = status.into(); rv["is_penalty_miss_ratio"] = status.into();
// Fields RecvMultiLiveEndRData declares that live_end does not emit. // Fields RecvMultiLiveEndRData declares that live_end does not emit.
@@ -837,166 +780,6 @@ mod tests {
assert_eq!(multi_live_end_status(&unknown), STATUS_NONE); assert_eq!(multi_live_end_status(&unknown), STATUS_NONE);
} }
// --- private vs public rooms (the score-board branch) --------------------------
//
// The relay runs in this process, so the end handler can ask the room registry what
// kind of room a finishing live belongs to. These drive the REAL (global) registry
// through the same entry points ws.rs uses, then ask is_private_party exactly what the
// handler asks it. Room names and tokens are unique per test, so they do not collide
// with each other in the shared registry.
fn open_room(name: &str, visible: bool, token: &str) -> rooms::ConnId {
// The receiver is dropped immediately: nothing here reads the relay's outbound
// frames, and a send to a gone writer is already a no-op (Registry::send).
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut reg = rooms::registry();
let id = reg.connect(1, tx, Instant::now());
reg.handle(id, ClientMsg::CreateRoom {
name: name.to_string(),
max_players: 4,
visible,
open: true,
props: Map::new(),
lobby_prop_keys: vec![],
player_props: Map::new(),
}, Instant::now());
// MultiEventMatchingScene mints the token and pushes the room bag just before the
// live starts; it is the same string the client then POSTs to /multi_live/end.
reg.handle(id, ClientMsg::SetRoomProps {
props: Map::from_pairs(vec![("C", Value::Str(token.to_string()))]),
}, Instant::now());
id
}
fn set_room_props(id: rooms::ConnId, props: Vec<(&str, Value)>) {
rooms::registry().handle(id, ClientMsg::SetRoomProps { props: Map::from_pairs(props) }, Instant::now());
}
// A clean leave by the last active player destroys the room, exactly as a party
// breaking up after the results does.
fn close_room(id: rooms::ConnId) {
rooms::registry().handle(id, ClientMsg::LeaveRoom, Instant::now());
}
#[test]
fn a_private_party_is_recognised_by_its_room_token() {
let room = open_room("042719", false, "1.private-party");
assert!(is_private_party(&object!{ token: "1.private-party" }));
close_room(room);
}
#[test]
fn a_public_room_stays_public_once_the_game_hides_it() {
// The whole reason privacy is latched at creation: MultiMatchingView closes and
// hides a PUBLIC room the moment its members are decided, so mid-live its flags are
// indistinguishable from a private room's.
let room = open_room("1234567", true, "2.public-room");
assert!(!is_private_party(&object!{ token: "2.public-room" }));
set_room_props(room, vec![("#253", Value::Bool(false)), ("#254", Value::Bool(false))]);
assert!(
!is_private_party(&object!{ token: "2.public-room" }),
"a hidden public room must not start updating score boards"
);
close_room(room);
}
#[test]
fn a_token_no_room_claims_falls_back_to_public() {
// Torn down before the end arrived, or a restart since the live started.
let room = open_room("3336667", true, "3.gone-by-then");
close_room(room);
assert!(!is_private_party(&object!{ token: "3.gone-by-then" }));
// Never existed, and an end with no token at all.
assert!(!is_private_party(&object!{ token: "4.never-existed" }));
assert!(!is_private_party(&object!{}));
}
// --- privacy is decided ONCE, at start ------------------------------------------
#[test]
fn the_privacy_answer_is_recorded_at_start_and_outlives_the_room() {
let room = open_room("551234", false, "5.recorded-private");
let mut start_body = object!{ token: "5.recorded-private", master_live_id: STOCK_LIVE_ID };
record_room_privacy(&mut start_body);
assert_eq!(start_body["room_private"].as_bool(), Some(true));
// The party breaks up: the room is gone by the time the ends land.
close_room(room);
assert!(!is_private_party(&start_body), "the live lookup can no longer answer");
// The recorded answer still does, so the score board is still updated.
assert!(recorded_privacy(Some(&start_body), &start_body));
}
#[test]
fn every_ender_of_one_party_reads_the_same_answer() {
// Four members, one room, four /multi_live/start posts — and then the ends arrive
// spread out, the last one after the room has been torn down. Asking the registry
// at END time made the last poster's live PUBLIC while the first three's were
// PRIVATE: one live, scored two different ways.
let room = open_room("552345", false, "6.four-enders");
let mut records: Vec<JsonValue> = (0..4)
.map(|_| object!{ token: "6.four-enders", master_live_id: STOCK_LIVE_ID })
.collect();
for record in records.iter_mut() {
record_room_privacy(record);
}
// Three end while the room is alive, the fourth after it is gone.
let end_body = object!{ token: "6.four-enders" };
let mut answers: Vec<bool> = records[..3]
.iter()
.map(|r| recorded_privacy(Some(r), &end_body))
.collect();
close_room(room);
answers.push(recorded_privacy(Some(&records[3]), &end_body));
assert_eq!(answers, vec![true; 4], "one live must score one way for everybody");
}
#[test]
fn a_registry_miss_at_end_no_longer_flips_a_private_live_to_public() {
let room = open_room("553456", false, "7.miss-at-end");
let mut start_body = object!{ token: "7.miss-at-end" };
record_room_privacy(&mut start_body);
close_room(room);
// The end-time lookup misses and resolves to public...
assert!(!is_private_party(&start_body));
// ...but it is not what is asked any more.
assert!(recorded_privacy(Some(&start_body), &start_body));
}
#[test]
fn a_public_room_records_public_and_stays_public() {
let room = open_room("554567", true, "8.recorded-public");
let mut start_body = object!{ token: "8.recorded-public" };
record_room_privacy(&mut start_body);
assert_eq!(start_body["room_private"].as_bool(), Some(false));
assert!(!recorded_privacy(Some(&start_body), &start_body));
close_room(room);
assert!(!recorded_privacy(Some(&start_body), &start_body));
}
#[test]
fn a_record_with_no_recorded_answer_falls_back_to_the_live_lookup() {
// Started before this was recorded, or started against a token the registry did
// not know (a start POST that arrived after its own room died). Nothing is written
// in that case, deliberately, so the fallback is what selects it.
let room = open_room("555678", false, "9.no-record");
let mut missed = object!{ token: "9.never-a-room" };
record_room_privacy(&mut missed);
assert!(missed["room_private"].is_null(), "a miss must record nothing");
let legacy = object!{ live_boost: 1 };
let end_body = object!{ token: "9.no-record" };
assert!(recorded_privacy(Some(&legacy), &end_body), "falls back to the live room");
close_room(room);
assert!(!recorded_privacy(Some(&legacy), &end_body), "and to public once it is gone");
}
#[test] #[test]
fn player_count_does_not_double_count_the_poster() { fn player_count_does_not_double_count_the_poster() {
let body = object!{ let body = object!{

View File

@@ -58,13 +58,6 @@ pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(90);
const PROP_ROOM_LEVEL: &str = "C0"; const PROP_ROOM_LEVEL: &str = "C0";
const PROP_ROOM_POWER: &str = "C1"; const PROP_ROOM_POWER: &str = "C1";
// MultiPlayProtocol.RoomConst.Token — "{userId}.{guid}", minted by the master client in
// MultiEventMatchingScene right before the live starts (CommitCurrentRoomToken +
// PushCurrentRoomData) and mirrored to the whole party. Every member posts it as the
// `token` field of /multi_live/start|end, so it is the only handle the HTTP side has on
// which room a finishing live belongs to. The relay never writes it, only reads it back.
const PROP_ROOM_TOKEN: &str = "C";
// Photon's well-known room properties are BYTE keys living in the same bag as the // Photon's well-known room properties are BYTE keys living in the same bag as the
// game's string keys; the client transport encodes a byte key as "#" + decimal. // game's string keys; the client transport encodes a byte key as "#" + decimal.
// GamePropertyKey.IsOpen is 253 and IsVisible is 254, and the surviving Photon.Realtime // GamePropertyKey.IsOpen is 253 and IsVisible is 254, and the surviving Photon.Realtime
@@ -133,18 +126,6 @@ struct Room {
max_players: u8, max_players: u8,
visible: bool, visible: bool,
open: bool, open: bool,
// Whether this room was created as a PRIVATE (join-by-code) party, latched once at
// creation and never touched again. /multi_live/end branches the score-board write on
// it, so it must NOT be re-derived from `visible` later: the game hides a room the
// moment its members are decided (MultiMatchingView.SetRoomOpenVisible(false)), which
// makes a public room mid-live indistinguishable from a private one by the live flags.
//
// The signal is the creation-time visibility, because that is exactly what separates
// the client's two create paths: MultiPlayManager.CreatePublicRoom issues
// CreateRoom("", 4, visible: true, open: true) and lets the server name the room, while
// CreatePrivateRoom issues CreateRoom(code, 4, visible: false, open: true) — a private
// party is by definition the one that is never listed for random matching.
private: bool,
props: Map, props: Map,
// Kept only so a future lobby-listing op can honour what the creator asked to // Kept only so a future lobby-listing op can honour what the creator asked to
// publish; the matcher reads C0/C1 straight off the room bag like Photon's SQL did. // publish; the matcher reads C0/C1 straight off the room bag like Photon's SQL did.
@@ -431,15 +412,12 @@ impl Registry {
// disagree the bag wins, because the bag is what every client polls. // disagree the bag wins, because the bag is what every client polls.
let (mut visible, mut open) = (visible, open); let (mut visible, mut open) = (visible, open);
apply_flag_props(&props, &mut visible, &mut open); apply_flag_props(&props, &mut visible, &mut open);
// Latched here, from the settled creation-time visibility, and never updated.
let private = !visible;
// The creator is master and takes actor 1. // The creator is master and takes actor 1.
let room = Room { let room = Room {
lobby, lobby,
max_players, max_players,
visible, visible,
open, open,
private,
props, props,
lobby_prop_keys, lobby_prop_keys,
// The creator has nobody to notify, but its bag is seeded from the op-4 // The creator has nobody to notify, but its bag is seeded from the op-4
@@ -916,26 +894,6 @@ impl Registry {
// --- introspection (tests, and a future webui panel) ---------------------- // --- introspection (tests, and a future webui panel) ----------------------
// Whether the room carrying this live token is a private (join-by-code) party, or None
// when no live room claims the token.
//
// The token is matched against the ROOM bag rather than against the poster's account:
// all up to four party members post the same token, and only the master ever wrote it,
// so the bag is the one place the HTTP and relay halves meet. Room names are globally
// unique but a token is not addressable by name, so this is a scan — the registry holds
// at most a handful of live rooms and this runs once per /multi_live/end.
pub fn token_room_is_private(&self, token: &str) -> Option<bool> {
if token.is_empty() {
// An unset room prop reads back as "" on the client, which would otherwise
// match every room that never had a token pushed.
return None;
}
self.rooms
.values()
.find(|room| room.props.get_str(PROP_ROOM_TOKEN) == Some(token))
.map(|room| room.private)
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn room_count(&self) -> usize { pub fn room_count(&self) -> usize {
self.rooms.len() self.rooms.len()
@@ -947,24 +905,8 @@ impl Registry {
} }
} }
// What /multi_live/start asks, while the room is certainly still alive: is this a private
// party, or does no live room claim the token at all? The distinction matters to the
// caller — an answer is recorded on the start record, a miss is not (see
// multi_live::record_room_privacy).
pub fn token_room_privacy(token: &str) -> Option<bool> {
registry().token_room_is_private(token)
}
// The same question with the miss folded away, for a start record from before the answer // This file is like 1.5k lines of tests :skull:
// was recorded at start time.
//
// A token no live room claims answers `false`. The room is torn down as soon as its last
// active player leaves cleanly, and an end can arrive after that (a client that quit the
// room before its POST landed, or a server restart), so "unknown" has to resolve to
// something — and public is the official behaviour, which is the safe side to be wrong on.
pub fn token_is_private_room(token: &str) -> bool {
token_room_privacy(token).unwrap_or(false)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -2300,88 +2242,6 @@ mod tests {
} }
} }
// --- live-token lookup (the /multi_live/end score-board branch) -----------------
//
// The end handler has nothing but the room token to go on, and has to tell a private
// party from public matchmaking with it. The trap is that the live flags cannot answer
// the question: the game hides a PUBLIC room as well, the moment its members are
// decided, so privacy is latched at creation instead.
// MultiPlayManager.CreatePrivateRoom: the 6-digit code is the room name, and the room
// is created hidden so random matching can never land in it.
fn create_private(h: &mut Harness, id: ConnId, code: &str) {
h.send(id, ClientMsg::CreateRoom {
name: code.to_string(),
max_players: 4,
visible: false,
open: true,
props: Map::new(),
lobby_prop_keys: vec![],
player_props: Map::new(),
});
}
// MultiEventMatchingScene: CommitCurrentRoomToken then PushCurrentRoomData, once, just
// before the live starts.
fn push_token(h: &mut Harness, id: ConnId, token: &str) {
h.send(id, ClientMsg::SetRoomProps {
props: Map::from_pairs(vec![(PROP_ROOM_TOKEN, Value::Str(token.to_string()))]),
});
}
#[test]
fn a_private_room_is_found_by_the_live_token_it_carries() {
let mut h = Harness::new();
let a = h.connect(1);
let b = h.connect(2);
create_private(&mut h, a, "042719");
h.send(b, ClientMsg::JoinRoom { name: "042719".into(), player_props: Map::new() });
push_token(&mut h, a, "1.abcd");
// Every party member posts this same token, so both ends resolve to the one room.
assert_eq!(h.reg.token_room_is_private("1.abcd"), Some(true));
}
#[test]
fn a_public_room_stays_public_after_the_game_hides_it() {
// The regression this whole field exists for: MultiMatchingView.SetRoomOpenVisible
// (false) closes and hides a public room once its members are decided, so by the
// time the live ends the current flags look exactly like a private room's.
let mut h = Harness::new();
let a = h.connect(1);
h.create(a, "1234567", vec![]);
push_token(&mut h, a, "1.efgh");
assert_eq!(h.reg.token_room_is_private("1.efgh"), Some(false));
set_flags(&mut h, a, vec![
(PROP_ROOM_IS_OPEN, Value::Bool(false)),
(PROP_ROOM_IS_VISIBLE, Value::Bool(false)),
]);
assert_eq!(
h.reg.token_room_is_private("1.efgh"),
Some(false),
"a hidden public room must not be mistaken for a private party"
);
}
#[test]
fn an_unknown_or_empty_token_matches_no_room() {
let mut h = Harness::new();
let a = h.connect(1);
create_private(&mut h, a, "042719");
push_token(&mut h, a, "1.abcd");
assert_eq!(h.reg.token_room_is_private("2.nope"), None);
// A room whose master never pushed a token reads back "" on the client; that must
// not match the first room in the registry.
assert_eq!(h.reg.token_room_is_private(""), None);
// And once the last active player leaves, the room — and the answer — is gone.
h.send(a, ClientMsg::LeaveRoom);
assert_eq!(h.reg.room_count(), 0);
assert_eq!(h.reg.token_room_is_private("1.abcd"), None);
}
#[test] #[test]
fn disconnect_drops_the_connection_record() { fn disconnect_drops_the_connection_record() {
let mut h = Harness::new(); let mut h = Harness::new();

View File

@@ -119,6 +119,37 @@ fn proxy_card_id(id: i64) -> i64 {
*rv *rv
} }
// A card row for a slot the account can't actually supply: level 1, unevolved.
// Only ever handed to viewers, never written back to the account
fn stand_in_card(master_card_id: i64) -> JsonValue {
object!{
id: master_card_id,
master_card_id: master_card_id,
exp: 0,
skill_exp: 0,
evolve: []
}
}
// The card object for one of the four favourite/guest slots. global::get_card
// hands back an empty object when the slot is 0 (never set) or names a card the
// account no longer holds, and an empty object reaches the client as
// master_card_id 0: Shock.CardData's constructor looks that up in masterdata and
// dereferences the row, so it throws before the guest cell is ever drawn. Same
// repair userdata::remove_deleted_custom_cards makes for a dead slot - the
// account's first card, then the default for an account holding none
fn slot_card(id: i64, user: &JsonValue) -> JsonValue {
let card = global::get_card(id, user);
if !card.is_empty() {
return card;
}
let first = &user["card_list"][0];
if !first.is_empty() {
return first.clone();
}
stand_in_card(DEFAULT_CARD)
}
pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) { pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) {
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] { for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
let id = user["user"][key].as_i64().unwrap_or(0); let id = user["user"][key].as_i64().unwrap_or(0);
@@ -176,12 +207,26 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) ->
let mut rv = object!{ let mut rv = object!{
user: user["user"].clone(), user: user["user"].clone(),
favorite_card: global::get_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user), favorite_card: slot_card(user["user"]["favorite_master_card_id"].as_i64().unwrap_or(0), &user),
guest_smile_card: global::get_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user), guest_smile_card: slot_card(user["user"]["guest_smile_master_card_id"].as_i64().unwrap_or(0), &user),
guest_cool_card: global::get_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user), guest_cool_card: slot_card(user["user"]["guest_cool_master_card_id"].as_i64().unwrap_or(0), &user),
guest_pure_card: global::get_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user) guest_pure_card: slot_card(user["user"]["guest_pure_master_card_id"].as_i64().unwrap_or(0), &user)
}; };
// The id fields have to name the same card as the objects: surfaces that
// resolve the id instead of the object (profile, friend detail) would
// otherwise look up the slot this just stood in for. A no-op for an account
// whose slots are all set
for (key, card) in [
("favorite_master_card_id", "favorite_card"),
("guest_smile_master_card_id", "guest_smile_card"),
("guest_cool_master_card_id", "guest_cool_card"),
("guest_pure_master_card_id", "guest_pure_card")
] {
let id = rv[card]["master_card_id"].clone();
rv["user"][key] = id;
}
if let UserView::Detail | UserView::Ranking = view { if let UserView::Detail | UserView::Ranking = view {
rv["main_deck_detail"] = object!{ rv["main_deck_detail"] = object!{
total_power: 0, total_power: 0,
@@ -237,6 +282,33 @@ mod tests {
} }
} }
// A slot the account can't supply still hands the viewer a resolvable card:
// an empty card object reaches the client as master_card_id 0, and
// Shock.CardData throws on an id that isn't in masterdata
#[test]
fn empty_slots_stand_in_for_a_real_card() {
let user = jzon::object!{
"user": { "favorite_master_card_id": 0 },
"card_list": [
{ "id": 40030007, "master_card_id": 40030007, "exp": 0, "skill_exp": 0, "evolve": [] },
{ "id": 10010001, "master_card_id": 10010001, "exp": 0, "skill_exp": 0, "evolve": [] }
]
};
assert_eq!(slot_card(40030007, &user)["master_card_id"].as_i64(), Some(40030007));
// Never set, and a card the account no longer holds, both fall back to
// its first card
assert_eq!(slot_card(0, &user)["master_card_id"].as_i64(), Some(40030007));
assert_eq!(slot_card(20010001, &user)["master_card_id"].as_i64(), Some(40030007));
// A tutorial-stage account holds nothing at all
let empty = jzon::object!{ "user": {}, "card_list": [] };
let card = slot_card(0, &empty);
assert_eq!(card["master_card_id"].as_i64(), Some(DEFAULT_CARD));
assert_eq!(card["id"].as_i64(), Some(DEFAULT_CARD));
assert!(card["evolve"].is_array());
assert!(!databases::CARD_LIST[DEFAULT_CARD.to_string()].is_empty());
}
// The imported band proxies to its own character's base card, not to a // The imported band proxies to its own character's base card, not to a
// single default (the old prefix arithmetic collapsed 14000+ to Honoka) // single default (the old prefix arithmetic collapsed 14000+ to Honoka)
#[test] #[test]

View File

@@ -67,8 +67,7 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
} }
} }
// Runtime custom cards are unresolvable below protocol 3. start.rs blocks // Don't allow account downgrade (will crash client)
// flagged accounts on old clients, so this is belt-and-braces
if !crate::router::custom_card::client_supports(&req) { if !crate::router::custom_card::client_supports(&req) {
crate::router::custom_card::strip_unsupported(&mut user); crate::router::custom_card::strip_unsupported(&mut user);
} }
@@ -182,9 +181,10 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
pub async fn announcement(Login(key): Login) -> impl Responder { pub async fn announcement(Login(key): Login) -> impl Responder {
let mut user = userdata::get_acc_home(&key); let mut user = userdata::get_acc_home(&key);
user["home"]["new_announcement_flag"] = (0).into(); user["home"]["new_announcement_flag"] = (0).into();
user["home"]["announcement_seen_at"] = (global::timestamp() as i64).into();
userdata::save_acc_home(&key, user); userdata::save_acc_home(&key, user);
Api(Some(object!{ Api(Some(object!{

View File

@@ -1,4 +1,5 @@
pub mod user; pub mod user;
pub mod starter;
use rusqlite::params; use rusqlite::params;
use lazy_static::lazy_static; use lazy_static::lazy_static;
@@ -787,6 +788,37 @@ pub fn export_user(token: &str) -> Option<JsonValue> {
}) })
} }
// Every row an account owns, gone. Factored out of purge_accounts so the arcade
// sweeper (a machine that aged out takes its two accounts with it) and the
// card-rebind orphan cleanup delete exactly the same set of rows - an account
// half-deleted here is one the login path would resurrect empty.
//
// This list is also what the arcade guest reset mirrors: starter::write_starter_rows
// rewrites the eleven data rows, re-draws the token and deletes the rest, so a
// row added here has to be accounted for there too or a guest starts carrying
// the previous player's state across a credit.
pub const ACCOUNT_TABLES: &[&str] = &[
"userdata", "userhome", "missions", "loginbonus", "sifcards", "friends",
"chats", "exchange", "event", "eventloginbonus", "server_data", "webui",
"tokens", "migration"
];
pub fn delete_account(user_id: i64) {
crate::database::gree::delete_uuid(user_id);
for table in ACCOUNT_TABLES {
DATABASE.lock_and_exec(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(user_id));
}
}
// True when the account has ever registered a data-transfer password, which is
// the only way an account can be taken over from another device. The arcade uses
// it to tell a throwaway account it made itself from a real player's account.
pub fn has_transfer_password(user_id: i64) -> bool {
!DATABASE.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(user_id))
.unwrap_or_default()
.is_empty()
}
pub fn purge_accounts() -> usize { pub fn purge_accounts() -> usize {
// If the user has no cards, its safe to assume its a dead account (imo). In the (rare) event this function is ran after a user started and before the account has characters, the server should create them a new account, and let them start the tutorial over. // If the user has no cards, its safe to assume its a dead account (imo). In the (rare) event this function is ran after a user started and before the account has characters, the server should create them a new account, and let them start the tutorial over.
let dead_uids = DATABASE.lock_and_select_all(" let dead_uids = DATABASE.lock_and_select_all("
@@ -802,21 +834,7 @@ pub fn purge_accounts() -> usize {
for uid in dead_uids.members() { for uid in dead_uids.members() {
let user_id = uid.as_i64().unwrap(); let user_id = uid.as_i64().unwrap();
println!("Removing dead UID: {}", user_id); println!("Removing dead UID: {}", user_id);
crate::database::gree::delete_uuid(user_id); delete_account(user_id);
DATABASE.lock_and_exec("DELETE FROM userdata WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM userhome WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM missions WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM loginbonus WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM sifcards WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM friends WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM chats WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM exchange WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM event WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM eventloginbonus WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM server_data WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM webui WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM tokens WHERE user_id=?1", params!(user_id));
DATABASE.lock_and_exec("DELETE FROM migration WHERE user_id=?1", params!(user_id));
} }
DATABASE.lock_and_exec("VACUUM", params!()); DATABASE.lock_and_exec("VACUUM", params!());
crate::database::gree::setup(); crate::database::gree::setup();

View File

@@ -0,0 +1,465 @@
// Accounts that are born finished.
//
// Everything below Title in the client assumes a tutorial-complete account: a
// name, a favourite card, a nine-card deck, tutorial_step 130. The tutorial that
// produces one is four client requests, and nothing on the server can create
// that state on its own - which is exactly what the arcade needs, twice per
// cabinet (the machine identity and its guest) and again at every credit (the
// guest, rewritten in place).
//
// So this module replays those four requests' mutations, in their order, off
// their own handlers' code:
//
// POST /api/lottery the tutorial draw leaves a card in card_list,
// which is what /user/initialize reads as the
// account's "ur" (lottery.rs:342-358, user.rs:383)
// POST /api/user/initialize favourite + guest cards, 3000 gems, the band
// title, the nine base cards, deck slot 1,
// character_list, the first bond mission
// (user.rs:371-453)
// POST /api/user the player's name (user.rs:138-140)
// POST /api/tutorial tutorial_step 130 and full stamina
// (tutorial.rs:13-17)
//
// The one deliberate difference is the gacha: a cabinet draws nothing random.
// The chosen member's own base card takes the "ur" slot instead, which leaves
// the starter deck complete (nine distinct cards) rather than one short, and
// makes every arcade account byte-identical apart from its name.
//
// `write_starter_rows` is the single source of truth for what an account is made
// of, shared by creation and by the guest reset - the two can never drift.
//
// It is also the single source of truth for what an account is NOT made of. A
// guest is handed to a stranger every credit, so the reset has to leave behind
// exactly what a freshly created account has: the eleven data rows, one login
// token, and nothing else. Every other row `delete_account` recognises as
// account-owned (mod.rs:791) is deleted here rather than rewritten - the
// transfer code and password /api/user/registerpassword registers, the webui
// session, the gree device certificate - because each of them is a credential
// the previous player could keep and come back with. The login token is the one
// thing that is kept in the sense that the account keeps having one, but it is
// re-drawn too: the old one is on the cabinet's disk and may be on the previous
// player's phone, and /api/arcade/session hands the new one straight back to the
// cabinet in the `uuid` the client already adopts.
use jzon::{array, object, JsonValue};
use rusqlite::params;
use crate::include_file;
use crate::router::{card, chat, global, items, live};
use super::{DATABASE, NEW_USER, acc_exists, generate_uid};
// The member every arcade account is built around: Kousaka Honoka, the first
// member of the first band, so the deck is deterministic and recognisable.
// user/initialize derives everything else from this one id.
const STARTER_CHARACTER_ID: i64 = 1001;
// The nine cards /user/initialize rewards for a mu's pick (user.rs:398)
const STARTER_CARDS: &[i64] = &[
10010001, 10020001, 10030001, 10040001, 10050001,
10060001, 10070001, 10080001, 10090001
];
// The card the tutorial gacha would have left in card_list[0]. See the module
// note: the chosen member's own base card, so the deck comes out whole.
const STARTER_UR: i64 = 10010001;
// user.rs:396-411: 3000000 + the band offset (0 for mu's) + the member's index
// within the band, which is the last two digits of the character id
const STARTER_TITLE_ID: i64 = 3_000_000 + STARTER_CHARACTER_ID % 100;
// Every account name is stored verbatim by /api/user, so a cabinet name is no
// more dangerous than a player name - but it arrives from a machine that types
// it once and never again, so it is clamped rather than trusted to be sane.
pub const MAX_NAME_LEN: usize = 32;
pub fn clean_name(name: &str, fallback: &str) -> String {
let name: String = name
.chars()
.filter(|c| !c.is_control())
.take(MAX_NAME_LEN)
.collect();
let name = name.trim();
if name.is_empty() {
return fallback.to_string();
}
name.to_string()
}
// The userdata blob a finished tutorial leaves behind, plus the two side rows it
// writes on the way (the mission progress and the chat it unlocks).
fn tutorial_complete(uid: i64, name: &str) -> (JsonValue, JsonValue, JsonValue, JsonValue) {
let now = global::timestamp();
// create_acc (mod.rs:230-232)
let mut user = NEW_USER.clone();
user["user"]["id"] = uid.into();
user["stamina"]["last_updated_time"] = now.into();
let mut home = jzon::parse(&include_file!("src/router/userdata/new_user_home.json")).unwrap();
let mut missions = jzon::parse(&include_file!("src/router/userdata/missions.json")).unwrap();
let mut chats = array![];
// --- POST /api/user/initialize (user.rs:371-453) ------------------------
chat::add_chat(STARTER_CHARACTER_ID, 1, &mut chats);
user["user"]["favorite_master_card_id"] = STARTER_UR.into();
user["user"]["guest_smile_master_card_id"] = STARTER_UR.into();
user["user"]["guest_cool_master_card_id"] = STARTER_UR.into();
user["user"]["guest_pure_master_card_id"] = STARTER_UR.into();
home["home"]["preset_setting"][0]["illust_master_card_id"] = STARTER_UR.into();
user["gem"]["free"] = (3000).into();
user["gem"]["total"] = (3000).into();
user["user"]["master_title_ids"][0] = STARTER_TITLE_ID.into();
// The clear-mission and chat out-parameters are thrown away here exactly as
// /user/initialize throws them away (user.rs:418): the only chat a fresh
// account keeps is the one added above.
for id in STARTER_CARDS {
items::give_character(*id, &mut user, &mut missions, &mut array![], &mut array![]);
}
let mut others = array![];
for id in STARTER_CARDS {
if id / 10000 != STARTER_CHARACTER_ID {
others.push(*id).unwrap();
}
}
for slot in 0..9 {
let card_id = if slot == 4 {
STARTER_UR
} else if slot < 4 {
others[slot].as_i64().unwrap_or(0)
} else {
others[slot - 1].as_i64().unwrap_or(0)
};
user["deck_list"][0]["main_card_ids"][slot] = card_id.into();
}
user["character_list"] = array![object!{
master_character_id: STARTER_CHARACTER_ID,
exp: 1
}];
let bond = live::bond_missions(STARTER_CHARACTER_ID);
if !bond.is_empty() {
items::advance_mission(bond[0][1].as_i64().unwrap(), 1, bond[0][0].as_i64().unwrap(), &mut missions);
}
// --- POST /api/user (user.rs:138-140) -----------------------------------
user["user"]["name"] = name.into();
// --- POST /api/tutorial, the final step (tutorial.rs:13-17) -------------
user["tutorial_step"] = (130).into();
user["stamina"]["stamina"] = (100).into();
user["stamina"]["last_updated_time"] = now.into();
(user, home, missions, chats)
}
// Everything create_acc seeds, as (table, value). The column always carries the
// table's own name; `userdata` is the one row with extra columns and is written
// by the caller.
fn side_rows(chats: &JsonValue, home: &JsonValue, missions: &JsonValue) -> Vec<(&'static str, String)> {
let now = global::timestamp();
vec![
("userhome", jzon::stringify(home.clone())),
("missions", jzon::stringify(missions.clone())),
("chats", jzon::stringify(chats.clone())),
("loginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)),
("eventloginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)),
("sifcards", String::from("[]")),
("friends", String::from(r#"{"friend_user_id_list":[],"request_user_id_list":[],"pending_user_id_list":[]}"#)),
("event", String::from("{}")),
("exchange", String::from("[]")),
("server_data", format!(r#"{{"server_time_set":{},"server_time":1709272800}}"#, now))
]
}
// The rows delete_account (mod.rs:791) removes that write_starter_rows does not
// write back: everything an account owns that is a credential rather than
// progress. `tokens` is not here because the starter write issues a fresh one in
// the same transaction, and `userdata` and its ten side tables are not here
// because they are rewritten. Anything added to delete_account's list belongs in
// one of those three places or the guest reset starts leaking again.
const CARRIED_CREDENTIAL_TABLES: &[&str] = &["migration", "webui"];
// Writes the whole account inside one transaction: the eleven data rows back to
// the starter state, every carried-over credential gone, and a login token
// nobody has seen before. Upserts rather than inserts, so the same code both
// creates an account and rewrites an existing one - the reset can never
// half-apply, and it cannot miss a table that creation seeds because there is
// only one list. Returns the account's new login token.
fn write_starter_rows(uid: i64, name: &str) -> Result<String, rusqlite::Error> {
let (user, home, missions, chats) = tutorial_complete(uid, name);
let rows = side_rows(&chats, &home, &missions);
let friend_request_disabled = user["user"]["friend_request_disabled"].as_i32().unwrap_or(1);
let protocol_version = if card::account_has_custom_cards(&user) { card::PROTOCOL_VERSION } else { 0 };
let userdata = jzon::stringify(user);
let token = global::create_token();
DATABASE.lock_and_transact(|conn| {
conn.execute(
"INSERT INTO userdata (user_id, userdata, friend_request_disabled, protocol_version) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(user_id) DO UPDATE SET userdata=?2, friend_request_disabled=?3, protocol_version=?4",
params!(uid, &userdata, friend_request_disabled, protocol_version)
)?;
for (table, value) in &rows {
conn.execute(
&format!(
"INSERT INTO {0} (user_id, {0}) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET {0}=?2",
table
),
params!(uid, value)
)?;
}
for table in CARRIED_CREDENTIAL_TABLES {
conn.execute(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(uid))?;
}
// The account's one login token, re-drawn. The DELETE by token is
// create_acc's own collision guard (mod.rs:238); the DELETE by user id is
// what makes the INSERT a rotation rather than a primary-key conflict.
conn.execute("DELETE FROM tokens WHERE token=?1", params!(&token))?;
conn.execute("DELETE FROM tokens WHERE user_id=?1", params!(uid))?;
conn.execute("INSERT INTO tokens (user_id, token) VALUES (?1, ?2)", params!(uid, &token))?;
Ok(token)
})
}
// A brand new account, already through the tutorial. Returns its user id and its
// login token - the uuid the client stores and authenticates with from then on.
pub fn create(name: &str) -> Option<(i64, String)> {
let uid = generate_uid();
match write_starter_rows(uid, name) {
Ok(token) => Some((uid, token)),
Err(err) => {
println!("arcade: could not create account {}: {}", uid, err);
None
}
}
}
// Rewrites an existing account back to the starter state, keeping its user id:
// the cabinet's guest, at the start of every credit. Returns the account's new
// login token, which /api/arcade/session answers with - the previous player's
// copy of the old one stops working the moment their credit ends.
pub fn reset(uid: i64, name: &str) -> Option<String> {
if !acc_exists(uid) {
return None;
}
let token = match write_starter_rows(uid, name) {
Ok(token) => token,
Err(err) => {
println!("arcade: could not reset account {}: {}", uid, err);
return None;
}
};
// The gree device certificate is the one account-owned credential that lives
// in another database, so it cannot ride the transaction above - but it is on
// delete_account's list for the same reason the two tables are, and a stale
// one would still name this account (database/gree.rs:98).
crate::database::gree::delete_uuid(uid);
Some(token)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::userdata;
fn stored(uid: i64) -> JsonValue {
jzon::parse(&DATABASE.lock_and_select("SELECT userdata FROM userdata WHERE user_id=?1", params!(uid)).unwrap()).unwrap()
}
fn row(uid: i64, table: &str) -> String {
DATABASE.lock_and_select(&format!("SELECT {0} FROM {0} WHERE user_id=?1", table), params!(uid)).unwrap()
}
// A created account is already past the tutorial: named, nine cards, a full
// deck with the favourite in the centre, gems, title, bond and full stamina
#[test]
fn a_created_account_is_tutorial_complete() {
let _lock = crate::runtime::lock_test_data_path();
let (uid, token) = create("Cabinet 1").unwrap();
assert_eq!(userdata::uid_from_login_token(&token), uid);
let user = stored(uid);
assert_eq!(user["user"]["id"].as_i64(), Some(uid));
assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1"));
// 130 is what every gate downstream tests for (live.rs:92, :385, :435)
assert_eq!(user["tutorial_step"].as_i64(), Some(130));
assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100));
assert_eq!(user["gem"]["free"].as_i64(), Some(3000));
assert_eq!(user["gem"]["total"].as_i64(), Some(3000));
assert_eq!(user["user"]["master_title_ids"][0].as_i64(), Some(3000001));
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(STARTER_UR));
assert_eq!(user["character_list"][0]["master_character_id"].as_i64(), Some(STARTER_CHARACTER_ID));
assert_eq!(user["card_list"].len(), STARTER_CARDS.len());
for id in STARTER_CARDS {
assert!(user["card_list"].members().any(|c| c["master_card_id"] == *id), "missing {}", id);
}
// The deck is whole: nine distinct cards, the favourite in the centre
let deck = &user["deck_list"][0]["main_card_ids"];
assert_eq!(deck.len(), 9);
assert_eq!(deck[4].as_i64(), Some(STARTER_UR));
let mut seen = Vec::new();
for slot in deck.members() {
let id = slot.as_i64().unwrap();
assert!(id != 0, "empty deck slot");
assert!(!seen.contains(&id), "duplicate card {} in the deck", id);
seen.push(id);
}
// The home row carries the favourite too, and every side row exists
assert_eq!(jzon::parse(&row(uid, "userhome")).unwrap()["home"]["preset_setting"][0]["illust_master_card_id"].as_i64(), Some(STARTER_UR));
assert_eq!(row(uid, "sifcards"), "[]");
assert_eq!(row(uid, "exchange"), "[]");
assert_eq!(row(uid, "event"), "{}");
assert!(!jzon::parse(&row(uid, "missions")).unwrap().is_empty());
assert!(!jzon::parse(&row(uid, "chats")).unwrap().is_empty());
// The account is NOT one of the dead ones the purge sweeper collects
// (mod.rs:792-801): it has cards, a real name and step 130
assert!(!user["card_list"].is_empty());
assert_ne!(user["user"]["name"].as_str(), Some("Tutorial in progress"));
}
// The guest reset keeps the identity and the token and throws away
// everything the last player did with it
#[test]
fn a_reset_keeps_the_identity_and_wipes_the_progress() {
let _lock = crate::runtime::lock_test_data_path();
let (uid, token) = create("GUEST").unwrap();
// A credit's worth of play, on every row a reset has to reach
let mut user = stored(uid);
user["user"]["name"] = "Somebody".into();
user["user"]["exp"] = (12345).into();
user["stamina"]["stamina"] = (3).into();
user["gem"]["free"] = (99999).into();
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 7, high_score: 654321, max_combo: 300 }).unwrap();
user["live_mission_list"].push(object!{ master_live_id: 1100101, clear_master_live_mission_ids: [1, 2] }).unwrap();
user["item_list"].push(object!{ id: 17001001, master_item_id: 17001001, amount: 50 }).unwrap();
user["card_list"].push(object!{ id: 10010013, master_card_id: 10010013, exp: 0, skill_exp: 0, evolve: [], created_date_time: 0 }).unwrap();
userdata::save_acc(&token, user);
userdata::save_acc_friends(&token, object!{
friend_user_id_list: [1],
request_user_id_list: [],
pending_user_id_list: []
});
userdata::save_server_data(&token, object!{ last_live_started: [object!{ master_live_id: 1100101 }] });
userdata::save_acc_exchange(&token, array![1, 2, 3]);
let new_token = reset(uid, "Cabinet 1").expect("the reset was refused");
// Same account, a new login token - the cabinet is handed the new one in
// the /api/arcade/session answer, and the previous player's copy of the
// old one now names nothing
assert_ne!(new_token, token, "the guest's login token was reused");
assert_eq!(userdata::uid_from_login_token(&new_token), uid);
assert_eq!(userdata::uid_from_login_token(&token), 0, "the previous credit's token still logs in");
let user = stored(uid);
assert_eq!(user["user"]["id"].as_i64(), Some(uid));
assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1"));
assert_eq!(user["user"]["exp"].as_i64(), Some(0));
assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100));
assert_eq!(user["gem"]["free"].as_i64(), Some(3000));
assert_eq!(user["live_list"].len(), 0);
assert_eq!(user["live_mission_list"].len(), 0);
assert_eq!(user["item_list"].len(), 0);
assert_eq!(user["card_list"].len(), STARTER_CARDS.len());
assert_eq!(user["deck_list"][0]["main_card_ids"][4].as_i64(), Some(STARTER_UR));
// Every side row went back too
assert_eq!(jzon::parse(&row(uid, "friends")).unwrap()["friend_user_id_list"].len(), 0);
assert!(jzon::parse(&row(uid, "server_data")).unwrap()["last_live_started"].is_null());
assert_eq!(row(uid, "exchange"), "[]");
}
// Resetting something that is not an account is refused rather than
// conjuring one out of nothing
#[test]
fn resetting_an_unknown_account_is_refused() {
let _lock = crate::runtime::lock_test_data_path();
assert!(reset(123, "Cabinet 1").is_none());
}
// A guest is handed to a stranger every credit, so a reset has to take every
// credential the last player could have left on it - not just the progress.
// The list is delete_account's (mod.rs:791) minus the rows the starter write
// rewrites and the token it re-draws.
#[test]
fn a_reset_takes_every_credential_the_last_player_left() {
let _lock = crate::runtime::lock_test_data_path();
let (uid, token) = create("GUEST").unwrap();
// The previous player, on the cabinet during their credit: a transfer
// code and password (/api/user/registerpassword), a webui session logged
// in with them, and a gree device certificate.
let code = userdata::user::migration::save_acc_transfer(uid, "hunter2");
assert!(userdata::has_transfer_password(uid));
let webui_token = userdata::webui_login(uid, "hunter2").expect("the webui login was refused");
assert_eq!(userdata::webui_login_token(&webui_token).as_deref(), Some(token.as_str()));
crate::database::gree::update_cert(uid, "a device certificate");
assert!(crate::database::gree::get_user_cert(&token).is_some());
let new_token = reset(uid, "Cabinet 1").expect("the reset was refused");
// The transfer code and password are gone: neither the game's own
// takeover check nor the webui login answers to them any more
assert!(!userdata::has_transfer_password(uid), "the transfer password survived the reset");
assert!(!userdata::user::migration::get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap_or(false),
"the previous player's transfer code still takes the account over");
assert!(userdata::webui_login(uid, "hunter2").is_err(), "the previous player can still log the webui in");
// The webui session they left open is gone too
assert!(userdata::webui_login_token(&webui_token).is_none(), "the previous player's webui session survived the reset");
assert!(userdata::webui_get_user(&webui_token).is_none());
// So is the device certificate, which named the account from a phone
assert!(crate::database::gree::get_user_cert(&token).is_none(), "the gree certificate survived the reset");
assert!(crate::database::gree::get_user_cert(&new_token).is_none());
// And the account is still perfectly usable under its new token
assert_eq!(userdata::uid_from_login_token(&new_token), uid);
assert_eq!(stored(uid)["user"]["name"].as_str(), Some("Cabinet 1"));
}
// Every table delete_account clears is either rewritten by the starter write,
// re-drawn (the token) or deleted by it. A row added to delete_account without
// a decision here is a leak across credits, so the lists are compared rather
// than trusted to have been kept in step.
#[test]
fn the_reset_accounts_for_every_table_a_deletion_clears() {
let rewritten = ["userdata", "userhome", "missions", "chats", "loginbonus",
"eventloginbonus", "sifcards", "friends", "event", "exchange", "server_data"];
for table in userdata::ACCOUNT_TABLES {
assert!(
rewritten.contains(table) || CARRIED_CREDENTIAL_TABLES.contains(table) || *table == "tokens",
"delete_account clears {} and the guest reset does nothing about it",
table
);
}
// And the side-row list really is what the reset rewrites
let rows = side_rows(&array![], &object!{}, &object!{});
for (table, _) in &rows {
assert!(rewritten.contains(table), "{} is written by the reset but not listed above", table);
}
assert_eq!(rows.len() + 1, rewritten.len());
}
// Cabinet names arrive from an operator typing into a machine
#[test]
fn names_are_clamped_not_trusted() {
assert_eq!(clean_name(" Cabinet 1 ", "ARCADE"), "Cabinet 1");
assert_eq!(clean_name("", "ARCADE"), "ARCADE");
assert_eq!(clean_name(" ", "ARCADE"), "ARCADE");
assert_eq!(clean_name("a\nb\tc", "ARCADE"), "abc");
assert_eq!(clean_name(&"x".repeat(200), "ARCADE").len(), MAX_NAME_LEN);
}
}

View File

@@ -38,6 +38,12 @@ pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
object!{success: false} object!{success: false}
} }
// 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()
}
pub fn save_acc_transfer(uid: i64, password: &str) -> String { pub fn save_acc_transfer(uid: i64, password: &str) -> String {
let database = userdata::get_userdata_database(); let database = userdata::get_userdata_database();
let token = if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) { let token = if let Ok(value) = database.lock_and_select("SELECT token FROM migration WHERE user_id=?1", params!(uid)) {

View File

@@ -1,6 +1,513 @@
use actix_web::{HttpResponse, HttpRequest}; use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
use actix_multipart::Multipart;
use futures_util::TryStreamExt;
use jzon::{array, object, JsonValue};
use include_dir::{include_dir, Dir};
use std::collections::{HashMap, HashSet};
pub fn announcement(_req: HttpRequest) -> HttpResponse { use crate::router::{global, userdata, webui};
use crate::database::{announcements, permissions};
HttpResponse::Ok().body("sif2 is back!") use crate::database::announcements::Banner;
static ASSETS: Dir<'_> = include_dir!("web_assets/announcement/");
const MAX_BANNER_BYTES: usize = 8 * 1024 * 1024;
const MAX_BANNER_DIM: u32 = 8192;
const MAX_SCALED_PIXELS: u64 = 16 * 1024 * 1024;
const BANNER_W: u32 = 420;
const BANNER_H: u32 = 168;
const CATEGORY_LABELS: &[(i64, &str, &str)] = &[
(1, "notice", "お知らせ"),
(2, "update", "アップデート"),
(3, "bug", "不具合")
];
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/web/announcement")
.route("", web::get().to(list))
.route("/detail", web::get().to(detail))
.route("/bulkRead", web::get().to(bulk_read))
.route("/assets/{file}", web::get().to(asset))
.route("/banner/{id}", web::get().to(banner_image))
);
cfg.service(
web::scope("/announcement")
.route("/list", web::get().to(admin_list))
.route("/create", web::post().to(create))
.route("/update", web::post().to(update))
.route("/delete", web::post().to(delete))
.route("/banner/{id}", web::get().to(admin_banner_image))
);
}
fn disabled() -> bool {
crate::get_args().hidden
}
fn query_i64(req: &HttpRequest, key: &str, def: i64) -> i64 {
req.query_string()
.split('&')
.find(|s| s.starts_with(&format!("{key}=")))
.and_then(|s| s.split('=').nth(1))
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(def)
}
fn player_key(req: &HttpRequest) -> Option<String> {
let key = global::get_login(req.headers(), "");
if key.is_empty() { None } else { Some(key) }
}
fn read_set(req: &HttpRequest) -> HashSet<i64> {
match player_key(req) {
Some(key) => { println!("Player has key"); userdata::get_acc_home(&key)["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect()},
None => { println!("No player key"); HashSet::new() }
}
}
fn mark_read(key: &str, ids: &[i64]) {
let mut user = userdata::get_acc_home(key);
let mut set: HashSet<i64> = user["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect();
for id in ids {
set.insert(*id);
}
let mut sorted: Vec<i64> = set.into_iter().collect();
sorted.sort();
let mut arr = array![];
for id in sorted {
arr.push(id).unwrap();
}
user["home"]["read_announcement_ids"] = arr;
userdata::save_acc_home(key, user);
}
fn display_date(published_at: i64) -> String {
if published_at <= 0 {
return String::new();
}
let s = global::format_datetime(published_at as u64);
format!("{}/{}/{} {}", &s[0..4], &s[5..7], &s[8..10], &s[11..16])
}
fn page_head() -> String {
String::from(r#"<!DOCTYPE html>n<html lang="ja-JP"><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=320, initial-scale=1.0, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/web/announcement/assets/sanitize.css" type="text/css">
<link rel="stylesheet" href="/web/announcement/assets/news_common.css" type="text/css">
<style>#tab div.on{background-image:none;background-color:#f93981;border-radius:0.6vw;}#news_list hr,#detail hr{background-image:none;background-color:#dfe6e6;}</style>
<script src="/web/announcement/assets/jquery-3.6.0.min.js"></script>
<script>var clicked=false;$(function(){$("a.once").on('click',function(){if(clicked){return false;}clicked=true;return true;});});window.addEventListener('pageshow',function(e){if(e.persisted){clicked=false;}});</script>
</head><body>"#)
}
fn page_foot() -> String {
String::from("</body></html>")
}
fn tab_bar(active: i64, read: &HashSet<i64>, bulk: bool) -> String {
let mut tabs = String::new();
for (cat, class, label) in CATEGORY_LABELS {
let unread = !bulk && announcements::visible_ids(Some(*cat)).iter().any(|id| !read.contains(id));
let badge = if unread {
String::from("<span class=\"new\"><img class=\"bg_badge_eff\" src=\"/web/announcement/assets/news_bg_badge_eff.png\"><img class=\"bg_badge\" src=\"/web/announcement/assets/news_bg_badge.png\"></span>")
} else {
String::new()
};
let inner = if *cat == active {
format!("<div class=\"on\">{label}</div>")
} else {
format!("<a class=\"once\" href=\"/web/announcement?category={cat}\">{label}</a>")
};
tabs.push_str(&format!("<div class=\"{class}\">{badge}{inner}</div>"));
}
let read_btn = if bulk {
String::from("<div class=\"read_btn\"><img class=\"off\" src=\"/web/announcement/assets/news_btn_bulk.png\"></div>")
} else {
format!("<div class=\"read_btn\"><a class=\"once\" href=\"/web/announcement/bulkRead?category={active}&amp;page=1\"><img src=\"/web/announcement/assets/news_btn_bulk.png\"></a></div>")
};
format!("<div id=\"header\"><div id=\"tab\">{tabs}{read_btn}</div></div><div id=\"tab_bottom\"></div>")
}
fn render_list(category: i64, read: &HashSet<i64>, bulk: bool) -> String {
let mut list = String::new();
let items = announcements::list_category(category);
if items.is_empty() {
list.push_str(&format!(r#"
<div class="info_title"><span class="title_text">No announcements right now</span></div>
"#));
}
for item in items.members() {
let id = item["id"].as_i64().unwrap_or(0);
let banner_url = if item["has_banner"].as_bool().unwrap_or(false) {
format!("/web/announcement/banner/{id}.png")
} else {
String::from("/web/announcement/assets/news_banner_generic_news.png")
};
let kind = item["type"].as_str().unwrap_or("news");
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
let update_text = if item["updated"].as_bool().unwrap_or(false) { "<span class=\"update_text\">- update</span>" } else { "" };
let new_badge = if !bulk && !read.contains(&id) {
String::from("<div class=\"info_new_image\"><img class=\"new\" src=\"/web/announcement/assets/news_icon_new.png\"></div>")
} else {
String::new()
};
let title = item["title"].as_str().unwrap_or("");
list.push_str(&format!(r#"
<li class="list_area"><div class="information_area"><div id="{id}" class="anchor"></div>
<a href="/web/announcement/detail?announcement_id={id}&amp;page=1" class="news">
<div class="main_image"><span class="banner"><img src="{banner_url}"></span></div>
<div class="information">
<div class="info_type_image"><img class="tag" src="/web/announcement/assets/news_icon_{kind}.png"></div>
<div class="info_date"><span class="date_text">{date}</span>{update_text}</div>
{new_badge}
<div class="clear"></div>
<div class="info_title"><span class="title_text">{title}</span></div>
</div>
<div class="arrow_image"><img class="arrow" src="/web/announcement/assets/news_img_arrow.png"></div>
</a></div><div class="clear"></div><hr></li>
"#));
}
format!("{}{}<div id=\"news_list\"><ul>{}</ul><div id=\"page_area\"><div id=\"paging\"><span class=\"page off\">1</span></div></div></div>{}",
page_head(), tab_bar(category, read, bulk), list, page_foot())
}
fn render_detail(item: &JsonValue, read: &HashSet<i64>) -> String {
let category = item["category"].as_i64().unwrap_or(1);
let title = item["title"].as_str().unwrap_or("");
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
let body = item["body"].as_str().unwrap_or("");
let banner = if item["has_banner"].as_bool().unwrap_or(false) {
let id = item["id"].as_i64().unwrap_or(0);
format!("<div class=\"detail_image\" style=\"display:block\"><img src=\"/web/announcement/banner/{id}.png\"></div>")
} else {
String::new()
};
format!("{}{}<div id=\"detail\"><div class=\"detail_text\"><div class=\"title\">{title}</div><div class=\"date\">{date}</div>{banner}<hr class=\"hr_detail\">{body}</div></div>{}",
page_head(), tab_bar(category, read, false), page_foot())
}
fn html(body: String) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType::html())
.body(body)
}
fn redirect_list(category: i64) -> HttpResponse {
HttpResponse::Found()
.insert_header(("Location", format!("/web/announcement?category={category}")))
.body("")
}
async fn list(req: HttpRequest) -> HttpResponse {
let category = query_i64(&req, "category", 1);
let category = if announcements::is_valid_category(category) { category } else { 1 };
html(render_list(category, &read_set(&req), false))
}
async fn detail(req: HttpRequest) -> HttpResponse {
let id = query_i64(&req, "announcement_id", 0);
let Some(item) = announcements::get(id) else {
return redirect_list(1);
};
if !item["visible"].as_bool().unwrap_or(false) {
return redirect_list(item["category"].as_i64().unwrap_or(1));
}
if let Some(key) = player_key(&req) {
mark_read(&key, &[id]);
}
html(render_detail(&item, &read_set(&req)))
}
async fn bulk_read(req: HttpRequest) -> HttpResponse {
let category = query_i64(&req, "category", 1);
let category = if announcements::is_valid_category(category) { category } else { 1 };
if let Some(key) = player_key(&req) {
mark_read(&key, &announcements::visible_ids(Some(category)));
}
html(render_list(category, &read_set(&req), true))
}
async fn asset(req: HttpRequest) -> HttpResponse {
let file = req.match_info().get("file").unwrap_or("");
let Some(file) = ASSETS.get_file(file) else {
return HttpResponse::NotFound().finish();
};
let body = file.contents();
let mime = mime_guess::from_path(file.path()).first_or_octet_stream();
HttpResponse::Ok()
.insert_header(ContentType(mime))
.insert_header(("content-length", body.len()))
.body(body)
}
fn png_response(bytes: Option<Vec<u8>>) -> HttpResponse {
match bytes {
Some(bytes) => HttpResponse::Ok()
.insert_header(ContentType::png())
.insert_header(("content-length", bytes.len()))
.body(bytes),
None => HttpResponse::NotFound().finish()
}
}
fn banner_id(req: &HttpRequest) -> i64 {
req.match_info().get("id").unwrap_or("").trim_end_matches(".png").parse::<i64>().unwrap_or(0)
}
async fn banner_image(req: HttpRequest) -> HttpResponse {
png_response(announcements::get_public_banner(banner_id(&req)))
}
async fn admin_banner_image(req: HttpRequest) -> HttpResponse {
if disabled() {
return HttpResponse::NotFound().finish();
}
if manager_uid(&req).filter(|uid| permissions::has(*uid, permissions::ANNOUNCEMENT_MANAGE)).is_none() {
return HttpResponse::NotFound().finish();
}
png_response(announcements::get_banner(banner_id(&req)))
}
type Fields = HashMap<String, Vec<u8>>;
async fn read_multipart(mut payload: Multipart) -> Result<Fields, String> {
let mut fields = Fields::new();
let mut total = 0usize;
while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? {
let name = field.name().unwrap_or("").to_string();
let mut data = Vec::new();
while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? {
total += chunk.len();
if total > MAX_BANNER_BYTES {
return Err(format!("Upload exceeds the {} MB limit", MAX_BANNER_BYTES / (1024 * 1024)));
}
data.extend_from_slice(&chunk);
}
fields.insert(name, data);
}
Ok(fields)
}
fn field_str(fields: &Fields, key: &str) -> String {
String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string()
}
fn field_flag(fields: &Fields, key: &str) -> bool {
matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on")
}
fn file_of<'a>(fields: &'a Fields, key: &str) -> Option<&'a Vec<u8>> {
fields.get(key).filter(|v| !v.is_empty())
}
fn process_banner(bytes: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(bytes).map_err(|_| String::from("The banner is not a decodable image (png, jpg and webp work)"))?;
if img.width() > MAX_BANNER_DIM || img.height() > MAX_BANNER_DIM {
return Err(format!("The banner is {}x{} - neither side may exceed {}px", img.width(), img.height(), MAX_BANNER_DIM));
}
let img = img.to_rgba8();
let scale = f64::max(BANNER_W as f64 / img.width() as f64, BANNER_H as f64 / img.height() as f64);
let scaled_w = ((img.width() as f64 * scale).round() as u32).max(1);
let scaled_h = ((img.height() as f64 * scale).round() as u32).max(1);
if (scaled_w as u64) * (scaled_h as u64) > MAX_SCALED_PIXELS {
return Err(format!("The banner is too far from the {}x{} banner shape to be cropped", BANNER_W, BANNER_H));
}
let scaled = image::imageops::resize(&img, scaled_w, scaled_h, image::imageops::FilterType::Lanczos3);
let x = (scaled.width() - BANNER_W.min(scaled.width())) / 2;
let y = (scaled.height() - BANNER_H.min(scaled.height())) / 2;
let cropped = image::imageops::crop_imm(&scaled, x, y, BANNER_W, BANNER_H).to_image();
let mut rv = Vec::new();
image::DynamicImage::ImageRgba8(cropped).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(rv)
}
fn send_json(resp: JsonValue) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
fn manager_uid(req: &HttpRequest) -> Option<i64> {
let token = webui::get_login_token(req)?;
let login_token = userdata::webui_login_token(&token)?;
userdata::get_acc(&login_token)["user"]["id"].as_i64()
}
fn require_manager(req: &HttpRequest) -> Result<i64, HttpResponse> {
if disabled() {
return Err(HttpResponse::NotFound().finish());
}
let Some(uid) = manager_uid(req) else {
return Err(webui::error("Not logged in"));
};
if !permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE) {
return Err(webui::error("You do not have permission to manage announcements"));
}
Ok(uid)
}
async fn admin_list(req: HttpRequest) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let mut categories = array![];
for (id, key, label) in CATEGORY_LABELS {
categories.push(object!{ id: *id, key: *key, label: *label }).unwrap();
}
let mut types = array![];
for kind in announcements::TYPES {
types.push(*kind).unwrap();
}
send_json(object!{
result: "OK",
data: {
announcements: announcements::get_all(),
categories: categories,
types: types
}
})
}
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
let uid = match require_manager(&req) {
Ok(uid) => uid,
Err(resp) => return resp
};
let fields = match read_multipart(payload).await {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match save_new(uid, &fields) {
Ok(id) => send_json(object!{ result: "OK", id: id }),
Err(e) => webui::error(&e)
}
}
fn published_at_of(raw: &str) -> i64 {
if raw.is_empty() {
global::timestamp() as i64
} else {
global::parse_datetime(raw).map(|t| t as i64).unwrap_or_else(|| global::timestamp() as i64)
}
}
fn save_new(uid: i64, fields: &Fields) -> Result<i64, String> {
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
if !announcements::is_valid_category(category) {
return Err(String::from("Invalid category"));
}
let kind = field_str(fields, "type");
if !announcements::is_valid_type(&kind) {
return Err(String::from("Invalid type"));
}
let title = field_str(fields, "title");
if title.is_empty() {
return Err(String::from("A title is required"));
}
let body = field_str(fields, "body");
let banner = match file_of(fields, "banner") {
Some(bytes) => Some(process_banner(bytes)?),
None => None
};
Ok(announcements::create(category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at_of(&field_str(fields, "published_at")), uid))
}
async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let fields = match read_multipart(payload).await {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match save_update(&fields) {
Ok(id) => send_json(object!{ result: "OK", id: id }),
Err(e) => webui::error(&e)
}
}
fn save_update(fields: &Fields) -> Result<i64, String> {
let id = field_str(fields, "id").parse::<i64>().unwrap_or(0);
let Some(stored) = announcements::get(id) else {
return Err(String::from("That announcement no longer exists"));
};
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
if !announcements::is_valid_category(category) {
return Err(String::from("Invalid category"));
}
let kind = field_str(fields, "type");
if !announcements::is_valid_type(&kind) {
return Err(String::from("Invalid type"));
}
let title = field_str(fields, "title");
if title.is_empty() {
return Err(String::from("A title is required"));
}
let body = field_str(fields, "body");
let banner = if let Some(bytes) = file_of(fields, "banner") {
Banner::Set(process_banner(bytes)?)
} else if field_flag(fields, "remove_banner") {
Banner::Clear
} else {
Banner::Keep
};
let published_at = if field_str(fields, "published_at").is_empty() {
stored["published_at"].as_i64().unwrap_or_else(|| global::timestamp() as i64)
} else {
published_at_of(&field_str(fields, "published_at"))
};
announcements::update(id, category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at);
Ok(id)
}
async fn delete(req: HttpRequest, body: String) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let body = jzon::parse(&body).unwrap_or(object!{});
let id = body["id"].as_i64().unwrap_or(0);
if announcements::get(id).is_none() {
return webui::error("That announcement no longer exists");
}
announcements::delete(id);
send_json(object!{ result: "OK" })
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([120, 90, 200, 255]));
let mut rv = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
rv
}
#[test]
fn banners_are_cropped_to_the_official_size() {
for (w, h) in [(420, 168), (1200, 300), (300, 1200), (64, 64)] {
let out = process_banner(&png(w, h)).unwrap();
let decoded = image::load_from_memory(&out).unwrap();
assert_eq!((decoded.width(), decoded.height()), (BANNER_W, BANNER_H), "source {}x{}", w, h);
}
assert!(process_banner(b"not an image").unwrap_err().contains("not a decodable image"));
}
#[test]
fn extreme_sources_are_refused_before_the_resize_allocates() {
let err = process_banner(&png(9000, 32)).unwrap_err();
assert!(err.contains("9000x32"), "got {}", err);
let err = process_banner(&png(24, 6000)).unwrap_err();
assert!(err.contains("banner shape"), "got {}", err);
}
} }

View File

@@ -229,6 +229,8 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
account_import: get_config()["import"].as_bool().unwrap(), account_import: get_config()["import"].as_bool().unwrap(),
custom_songs: !crate::router::custom_song::disabled(), custom_songs: !crate::router::custom_song::disabled(),
custom_cards: !crate::router::custom_card::disabled(), custom_cards: !crate::router::custom_card::disabled(),
custom_3dmv: !crate::router::custom_3dmv::disabled(),
arcade: !crate::router::arcade::disabled(),
links: { links: {
global: args.global_android, global: args.global_android,
japan: args.japan_android, japan: args.japan_android,
@@ -367,9 +369,6 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
} }
lazy_static! { lazy_static! {
// The selectable character list for the custom-card form: every official
// and SIF1-imported character in the baked csv, by name. The import band
// starts at 5001 (5001-5172 + 6001-6009); official ids top out at 4014
static ref CHARACTER_CHOICES: JsonValue = { static ref CHARACTER_CHOICES: JsonValue = {
let mut rv = jzon::array![]; let mut rv = jzon::array![];
for row in crate::router::databases::csv::table(Region::Jp, "character").members() { for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
@@ -384,8 +383,6 @@ lazy_static! {
rv rv
}; };
// The skill_center table with its display strings, for picking a center
// skill by name instead of by raw id
static ref SKILL_CENTER_CHOICES: JsonValue = { static ref SKILL_CENTER_CHOICES: JsonValue = {
let mut en_rows = object!{}; let mut en_rows = object!{};
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() { for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
@@ -406,9 +403,6 @@ lazy_static! {
}; };
} }
// The characters a card upload may reference, for the webui's searchable
// picker: the baked official + imported list, plus the custom characters
// this session may build on (their own and the publicly visible ones)
pub fn list_characters(req: HttpRequest) -> HttpResponse { pub fn list_characters(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
@@ -446,8 +440,6 @@ pub fn list_skill_centers(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The concrete upload bounds (per-rarity stat caps, enum ranges, skill array
// lengths) so the form enforces them before submitting
pub fn custom_card_limits(req: HttpRequest) -> HttpResponse { pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
if session_uid(&req).is_none() { if session_uid(&req).is_none() {
return error("Not logged in"); return error("Not logged in");
@@ -461,8 +453,22 @@ pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The requesting user's own effective scopes, for webui nav gating. Any pub fn custom_3dmv_limits(req: HttpRequest) -> HttpResponse {
// session may ask - it only ever reveals what the user themselves holds if crate::router::custom_3dmv::disabled() {
return HttpResponse::NotFound().finish();
}
if session_uid(&req).is_none() {
return error("Not logged in");
}
let resp = object!{
result: "OK",
data: crate::router::custom_3dmv::upload_limits()
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn my_scopes(req: HttpRequest) -> HttpResponse { pub fn my_scopes(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
@@ -471,12 +477,18 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
result: "OK", result: "OK",
data: { data: {
uid: uid, uid: uid,
scopes: permissions::scopes_for(uid), scopes: permissions::get_user_permissions(uid),
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD), can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH), can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT), can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|| permissions::has(uid, permissions::PERMISSION_REVOKE) || permissions::has(uid, permissions::PERMISSION_REVOKE),
can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE),
// Cabinets are server hardware, not user content: there is no
// finer-grained arcade scope, so managing them takes the top-level
// one that every --owner uid holds implicitly
can_manage_arcade: !crate::router::arcade::disabled() && permissions::has(uid, permissions::ALL)
} }
}; };
HttpResponse::Ok() HttpResponse::Ok()
@@ -484,8 +496,6 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The admin view: every grant plus the grantable vocabulary. Needs a
// permission.* scope - my_scopes is the anyone-can-ask endpoint
pub fn list_permissions(req: HttpRequest) -> HttpResponse { pub fn list_permissions(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
@@ -505,7 +515,7 @@ pub fn list_permissions(req: HttpRequest) -> HttpResponse {
uid: uid, uid: uid,
can_grant: can_grant, can_grant: can_grant,
can_revoke: can_revoke, can_revoke: can_revoke,
scopes: permissions::scopes_for(uid), scopes: permissions::get_user_permissions(uid),
available: available, available: available,
grants: permissions::grants() grants: permissions::grants()
} }
@@ -552,6 +562,84 @@ pub fn revoke_permission(req: HttpRequest, body: String) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The operator's cabinet list. Owner scope only: a machine row names the two
// accounts a cabinet owns, and removing one deletes them.
pub fn list_arcade_machines(req: HttpRequest) -> HttpResponse {
if crate::router::arcade::disabled() {
return HttpResponse::NotFound().finish();
}
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
if !permissions::has(uid, permissions::ALL) {
return error("You do not have permission to manage arcade machines");
}
let resp = object!{
result: "OK",
data: {
machines: crate::router::arcade::webui_machines()
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn remove_arcade_machine(req: HttpRequest, body: String) -> HttpResponse {
if crate::router::arcade::disabled() {
return HttpResponse::NotFound().finish();
}
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
if !permissions::has(uid, permissions::ALL) {
return error("You do not have permission to manage arcade machines");
}
let body = jzon::parse(&body).unwrap_or(object!{});
if let Err(e) = crate::router::arcade::webui_remove_machine(body["machine_id"].as_str().unwrap_or("")) {
return error(&e);
}
let resp = object!{
result: "OK"
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
// The account page's "bind arcade card" form. The cabinet's own endpoint speaks
// the encrypted game protocol behind the asset gate, which a browser cannot, so
// this is the browser's door onto the same rule - arcade::bind_card, not a copy
// of it. A webui session is required on top of the transfer code and password:
// the page is behind login anyway, and the extra proof costs nothing.
pub fn bind_arcade_card(req: HttpRequest, body: String) -> HttpResponse {
if crate::router::arcade::disabled() {
return HttpResponse::NotFound().finish();
}
if session_uid(&req).is_none() {
return error("Not logged in");
}
let body = jzon::parse(&body).unwrap_or(object!{});
match crate::router::arcade::bind_card(
body["card_id"].as_str().unwrap_or(""),
body["migrationCode"].as_str().unwrap_or(""),
body["pass"].as_str().unwrap_or("")
) {
Ok(user_id) => {
let resp = object!{
result: "OK",
data: {
user_id: user_id
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
},
Err(reason) => error(&reason)
}
}
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse { pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req); let token = get_login_token(&req);
if token.is_none() { if token.is_none() {
@@ -592,6 +680,11 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// rest of file is tests that ai wrote
// I didn't read through them because I don't super care about tests but they probably do something
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -25,6 +25,7 @@ pub struct HostConfig {
pub en_android_asset_hash: String, pub en_android_asset_hash: String,
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,
} }
// 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
@@ -37,6 +38,10 @@ pub fn set_enable_custom_cards(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_cards = enabled; HOST_CONFIG.write().unwrap().enable_custom_cards = enabled;
} }
pub fn set_enable_custom_3dmv(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_3dmv = 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
@@ -162,14 +167,17 @@ pub fn overlay_args(args: &mut crate::options::Args) {
} }
overlay_str!(jp_android_asset_hash); overlay_str!(jp_android_asset_hash);
overlay_str!(en_android_asset_hash); overlay_str!(en_android_asset_hash);
// Overlay only ever enables the features; a command-line --enable-custom-songs // Overlay only ever enables the features; a command-line --enable-custom-*
// / --enable-custom-cards is never overridden back to off // flag is never overridden back to off
if cfg.enable_custom_songs { if cfg.enable_custom_songs {
args.enable_custom_songs = true; args.enable_custom_songs = true;
} }
if cfg.enable_custom_cards { if cfg.enable_custom_cards {
args.enable_custom_cards = true; args.enable_custom_cards = true;
} }
if cfg.enable_custom_3dmv {
args.enable_custom_3dmv = 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????
@@ -192,5 +200,6 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
// while holding the lock // while holding the lock
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);
guard guard
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -0,0 +1,465 @@

/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
背景設定
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
html {
box-sizing: border-box; /* 1 */
cursor: default; /* 2 */
line-height: 1.5; /* 3 */
-ms-text-size-adjust: 100%; /* 4 */
-webkit-text-size-adjust: 100%; /* 5 */
}
*,
::before,
::after {
/* 表示無い個所の処理 */
background-repeat: no-repeat; /* 1 */
box-sizing: inherit; /* 2 */
}
body {
font-family: 'YuGothic','Yu Gothic','Hiragino Kaku Gothic ProN','ヒラギノ角ゴ ProN W3',sans-serif;
font-size: 2.0vw;
font-weight: normal;
color: #494949;
background-color: #f5ffff;
background-size: cover;
background-attachment: fixed;
-webkit-touch-callout: none; /* リンク長押しのポップアップを無効化*/
-webkit-user-select: none; /* テキスト長押しの選択ボックスを無効化*/
-webkit-tap-highlight-color:rgba(0,0,0,0);/*リンクの領域表示を無効化*/
}
/* ダークモード */
@media (prefers-color-scheme: dark) {
body {
background-color: #f5ffff;
color: #494949;
}
#header {
background-color: #f5ffff;
}
#tab div.on {
color: #f5ffff;
}
.detail_text {
color: #494949;
}
#tab div a, #tab div a:visited {
color: #494949;
}
#news_list .news .info_date {
color: #828282;
}
}
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
文字大きさ、ポジション 幅1580px基準のvw
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
/* 詳細本文 */
/* 40文字×無制限 */
.detail_text {
font-size: 2.0vw;
color: #494949;
font-weight: normal;
}
/* 装飾用 */
/* <div class="title"> */
#detail .title {
font-weight: bold;
}
/* <span class="bold"> */
#detail .bold {
font-weight: bold;
}
/* <div class="date"> */
#detail .date {
color: #f93981;
}
/* <span class="pink"> */
#detail .pink {
color: #f93981;
}
/* 見出し用 *
/* 更新日 24px */
.date_text {
font-size: 1.6vw;
font-weight: normal;
}
/* タイトル 32px */
.title_text {
font-size: 2.0vw;
font-weight: normal;
}
.update_text {
font-size: 1.3vw;
font-weight: normal;
}
#tab {
font-size: 2.0vw;
font-weight: normal;
}
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
メニュータブ
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
#header {
position: fixed;
top: 0;
width: 100vw;
height: 7.2vw;
background-color: #F2FFFF;
z-index: 1;
}
#tab {
position: relative;
top: 0;
margin: 1.6vw 1.3vw;
width: 97.4vw;
height: 4.0vw;
z-index: 1;
}
#tab>div {
float: left;
width: 18.9vw;
height: 4.0vw;
color: #494949;
text-align: center;
line-height: 4.0vw;
position: relative;
}
#tab div:nth-child(1), #tab div:nth-child(2) {
border-right: #CDD0D0 1px solid;
}
#tab div a, #tab div a:visited {
color: #494949;
}
#tab div.on {
color: #f5ffff;
background-image: url("../0_common_images/news_img_tab.png");
background-repeat: repeat-x;
background-size: contain;
}
#tab a {
display: block;
height: 100%;
background-position: center;
background-size: 15.5vw;
text-decoration: none;
}
#tab .tab_text {
text-align: center;
line-height: 100%;
}
#tab .new img.bg_badge {
position: absolute;
z-index: 2;
top: -0.5vw;
right: 1.1vw;
width: 2.4vw;
height: auto;
}
#tab .new img.bg_badge_eff {
position: absolute;
z-index: 1;
top: -1.3vw;
right: 0.3vw;
width: 4.0vw;
height: auto;
}
#tab>div.read_btn, #tab>div.more_btn {
float: right;
width: 15.6vw;
height: 4.0vw;
margin: 0vw -0.4vw 0vw 0.4vw;
}
#tab .read_btn img, #tab .more_btn img {
width: 15.6vw;
height: auto;
}
#tab img.off {
opacity: 0.5;
}
#tab_bottom {
margin-top: 7.2vw;
}
/*+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+
共通項目
+-+-+-+-+-+-+-+-+-+-+-++-+-+-+-+-+*/
/*種類アイコン画像*/
.info_type_image img.tag {
width : 13.7vw; /* 画像を枠の100%の横幅にする */
height: auto; /* 画像の縦幅を自動調整 */
}
.info_type_image img.present {
width : 1.5vw;
height: auto;
margin-left: 0.5vw;
}
.information_area {
width: 100%;
overflow: hidden;
}
.information {
position: relative;
float: left;
overflow: hidden;
}
.information img.new {
width : 8.3vw;
height: auto;
}
img.arrow {
width : 1.8vw;
height: auto;
margin-top: 2.4vw;
margin-left: 1.0vw;
margin-right: 0.4vw;
}
img.arrow_back {
width : 1.8vw;
height: auto;
margin-top: 2.4vw;
margin-right: 1.4vw;
transform: scale(-1, 1);
}
.info_title {
margin-top: 0vw;
word-break: break-all;
}
.anchor {
position: absolute;
margin-top: -7.2vw;
}
.clear {
clear: both;
}
#outer {
position:absolute;
top:0;
left:0;
width:100%;
height:100%;
}
#outer #center {
height:100%;
width:100%;
display:table;
}
#outer #center p {
display: table-cell;
height: 100%;
text-align: center;
vertical-align: middle;
}
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
お知らせリスト設定
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
#news_list .main_image img {
width : 20.86vw;
height: 8.34vw;
float: left;
}
#news_list .news {
display: block;
width: 100%;
height: 100%;
text-decoration: none;
color: #494949;
-webkit-tap-highlight-color: rgba(0,0,0,0);/*リンクの領域表示を無効化*/
overflow: hidden;
}
#news_list .news .info_date {
color: #828282;
}
#news_list ul {
margin: 1.3vw 1.3vw 0;
padding: 0;
width: 97.4vw;
}
#news_list li {
list-style: none;
}
#news_list hr, #detail hr {
background-image: url("../0_common_images/news_img_line.png");
background-repeat: repeat-x;
background-size: contain;
height: 0.26vw;
border: 0;
margin-top: 0.8vw;
margin-bottom: 0.8vw;
}
#news_list hr.hr_detail {
margin-top: 1.1vw;
margin-bottom: 1.1vw;
}
#news_list .list_area {
overflow: hidden;
}
#news_list .information {
float: left;
margin-left: 2.0vw;
width: 70.5vw;
}
#news_list .info_type_image {
float: left;
margin-top: 0.8vw;
width: 13.7vw;
}
#news_list .info_date {
float: left;
margin-top: 1.0vw;
margin-left: 2.0vw;
}
#news_list .info_new_image {
float: right;
margin-top: 0.7vw;
width: 8.3vw;
}
#news_list .arrow_image {
float: left;
width: 3.2vw;
height: 8.3vw;
}
#page_area {
margin-top: 3%;
padding: 0 2.0vw 4.0vw 2.0vw;
width: 100%;
overflow: auto;
font-size: 1.2vw;
display: none;
}
#paging {
text-align: center;
}
#page_area .page {
font-size: 1.8vw;
font-weight: bold;
padding-left: 1.5vw;
padding-right: 1.5vw;
color: #191919;
text-decoration: none;
}
#page_area .page.new {
color: #c12424;
}
#page_area .page.off {
color: #aaaaaa;
}
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
詳細ページ設定
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
#detail {
width: 100%;
height: 100%;
}
#detail .detail_text {
width : 97.4vw;
height : auto;
vertical-align:middle;
padding: 0;
margin: 0vw 1.3vw 1.3vw;
}
#detail .detail_image {
width: 100%;
padding: 1.0vw;
text-align: center;
display: none;
}
#detail .detail_image img {
width: 45.0vw;
height: auto;
}
.detail_text img {
max-width: 100%;
}
/*+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
外部コンテンツページ設定
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+*/
#contents {
width: 100%;
height: 100%;
overflow: hidden;
}
#contents #banner_list {
position: relative;
left: 3vw;
}
#contents .banner {
float: left;
padding: 0.6vw;
}
#contents .banner:nth-child(3n+4) {
clear: both;
}
#contents .clear {
clear: both;
}
#contents img {
width: 30.0vw;
height: auto;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,550 @@
/*! sanitize.css v4.0.0 | CC0 License | github.com/10up/sanitize.css */
/* Display definitions
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
* 2. Add the correct display in IE.
*/
article,
aside,
details, /* 1 */
figcaption,
figure,
footer,
header,
main, /* 2 */
menu,
nav,
section,
summary { /* 1 */
display: block;
}
/**
* Add the correct display in IE 9-.
*/
audio,
canvas,
progress,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Add the correct display in IE 10-.
* 1. Add the correct display in IE.
*/
template, /* 1 */
[hidden] {
display: none;
}
/* Elements of HTML (https://www.w3.org/TR/html5/semantics.html)
========================================================================== */
/**
* 1. Remove repeating backgrounds in all browsers (opinionated).
* 2. Add box sizing inheritence in all browsers (opinionated).
*/
*,
::before,
::after {
background-repeat: no-repeat; /* 1 */
box-sizing: inherit; /* 2 */
}
/**
* 1. Add text decoration inheritance in all browsers (opinionated).
* 2. Add vertical alignment inheritence in all browsers (opinionated).
*/
::before,
::after {
text-decoration: inherit; /* 1 */
vertical-align: inherit; /* 2 */
}
/**
* 1. Add border box sizing in all browsers (opinionated).
* 2. Add the default cursor in all browsers (opinionated).
* 3. Add a flattened line height in all browsers (opinionated).
* 4. Prevent font size adjustments after orientation changes in IE and iOS.
*/
html {
box-sizing: border-box; /* 1 */
cursor: default; /* 2 */
font-family: sans-serif; /* 3 */
line-height: 1.5; /* 3 */
-ms-text-size-adjust: 100%; /* 4 */
-webkit-text-size-adjust: 100%; /* 5 */
}
/* Sections (https://www.w3.org/TR/html5/sections.html)
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Correct the font sizes and margins on `h1` elements within
* `section` and `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: .67em 0;
}
/* Grouping content (https://www.w3.org/TR/html5/grouping-content.html)
========================================================================== */
/**
* 1. Correct font sizing inheritance and scaling in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
pre,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* 1. Correct the height in Firefox.
* 2. Add visible overflow in Edge and IE.
*/
hr {
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* Remove the list style on navigation lists in all browsers (opinionated).
*/
nav ol,
nav ul {
list-style: none;
}
/* Text-level semantics
========================================================================== */
/**
* 1. Add a bordered underline effect in all browsers.
* 2. Remove text decoration in Firefox 40+.
*/
abbr[title] {
border-bottom: 1px dotted; /* 1 */
text-decoration: none; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct colors in IE 9-.
*/
mark {
background-color: #ffff00;
color: #000000;
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Correct the font size in all browsers.
*/
small {
font-size: 83.3333%;
}
/**
* Change the positioning on superscript and subscript elements
* in all browsers (opinionated).
* 1. Correct the font size in all browsers.
*/
sub,
sup {
font-size: 83.3333%; /* 1 */
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
/*
* Remove the text shadow on text selections (opinionated).
* 1. Restore the coloring undone by defining the text shadow (opinionated).
*/
::-moz-selection {
background-color: #b3d4fc; /* 1 */
color: #000000; /* 1 */
text-shadow: none;
}
::selection {
background-color: #b3d4fc; /* 1 */
color: #000000; /* 1 */
text-shadow: none;
}
/* Embedded content (https://www.w3.org/TR/html5/embedded-content-0.html)
========================================================================== */
/*
* Change the alignment on media elements in all browers (opinionated).
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Change the fill color to match the text color in all browsers (opinionated).
*/
svg {
fill: currentColor;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Links (https://www.w3.org/TR/html5/links.html#links)
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove the gaps in underlines in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* Remove the outline when hovering in all browsers (opinionated.
*/
:hover {
outline-width: 0;
}
/* Tabular data (https://www.w3.org/TR/html5/tabular-data.html)
========================================================================== */
/*
* Remove border spacing in all browsers (opinionated).
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
/* transform-style: (https://www.w3.org/TR/html5/forms.html)
========================================================================== */
/**
* 1. Remove the default styling in all browsers (opinionated).
* 3. Remove the margin in Firefox and Safari.
*/
/* button, */
input,
select
/* textarea */
{
background-color: transparent; /* 1 */
border-style: none; /* 1 */
color: inherit; /* 1 */
font-size: 1em; /* 1 */
margin: 0; /* 3 */
}
/**
* Correct the overflow in IE.
* 1. Correct the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance in Edge, Firefox, and IE.
* 1. Remove the inheritance in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* 1. Prevent the WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Correct the focus styles unset by the previous rule.
*/
:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the border, margin, and padding in all browsers.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: .35em .625em .75em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 2 */
white-space: normal; /* 1 */
}
/**
* 1. Remove the vertical scrollbar in IE.
* 2. Change the resize direction on textareas in all browsers (opinionated).
*/
textarea {
overflow: auto; /* 1 */
resize: vertical; /* 2 */
}
/**
* Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
padding: 0;
}
/**
* Correct the cursor style on increment and decrement buttons in Chrome.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari for OS X.
*/
::-webkit-search-cancel-button,
::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Correct the text style on placeholders in Chrome, Edge, and Safari.
*/
::-webkit-input-placeholder {
color: inherit;
opacity: .54;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* WAI-ARIA (https://www.w3.org/TR/html5/dom.html#wai-aria)
========================================================================== */
/**
* Change the cursor on busy elements (opinionated).
*/
[aria-busy="true"] {
cursor: progress;
}
/*
* Change the cursor on control elements (opinionated).
*/
[aria-controls] {
cursor: pointer;
}
/*
* Change the cursor on disabled, not-editable, or otherwise
* inoperable elements (opinionated).
*/
[aria-disabled] {
cursor: default;
}
/* User interaction (https://www.w3.org/TR/html5/editing.html)
========================================================================== */
/*
* Remove the tapping delay on clickable elements (opinionated).
* 1. Remove the tapping delay in IE 10.
*/
a,
area,
button,
input,
label,
select,
textarea,
[tabindex] {
-ms-touch-action: manipulation; /* 1 */
touch-action: manipulation;
}
/*
* Change the display on visually hidden accessible elements (opinionated).
*/
[hidden][aria-hidden="false"] {
clip: rect(0, 0, 0, 0);
display: inherit;
position: absolute;
}
[hidden][aria-hidden="false"]:focus {
clip: auto;
}

2
webui

Submodule webui updated: 700dae60a3...c17a5b00dc