Compare commits
7 Commits
0df2741251
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5228e9f5 | ||
|
|
1d12a9415c | ||
|
|
4dcf73e5d4 | ||
|
|
06c9273151 | ||
|
|
d975b87799 | ||
|
|
cb40d74c2c | ||
|
|
8f7346d09e |
1
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
267
src/database/announcements.rs
Normal 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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
88
src/router/custom_3dmv/package.rs
Normal 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(())
|
||||||
|
}
|
||||||
121
src/router/custom_3dmv/vmd.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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!{
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -184,6 +183,7 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
465
src/router/userdata/starter.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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};
|
||||||
|
use crate::database::announcements::Banner;
|
||||||
|
|
||||||
HttpResponse::Ok().body("sif2 is back!")
|
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}&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}&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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
2
web_assets/announcement/jquery-3.6.0.min.js
vendored
Normal file
BIN
web_assets/announcement/news_banner_generic_news.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_assets/announcement/news_bg_badge.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
web_assets/announcement/news_bg_badge_eff.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
web_assets/announcement/news_btn_bulk.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
465
web_assets/announcement/news_common.css
Normal 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;
|
||||||
|
}
|
||||||
BIN
web_assets/announcement/news_icon_event.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
web_assets/announcement/news_icon_gacha.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web_assets/announcement/news_icon_maintenance.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
web_assets/announcement/news_icon_new.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
web_assets/announcement/news_icon_news.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
web_assets/announcement/news_icon_others.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
web_assets/announcement/news_icon_shop.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
web_assets/announcement/news_img_arrow.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
550
web_assets/announcement/sanitize.css
Normal 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;
|
||||||
|
}
|
||||||