46 Commits
Author SHA1 Message Date
Ethan O'Brien 78362608c8 Fix inability to set some options 2026-09-27 14:26:59 -05:00
Ethan O'Brien 8c58ff5b01 Bump version to 2.0.0 2026-09-27 14:06:23 -05:00
Ethan O'Brien 536e66811f Expose more settings 2026-09-27 14:05:08 -05:00
Ethan O'Brien 5a55bbf32a New assets 2026-09-27 12:52:05 -05:00
Ethan O'Brien 02afd15890 reorganize 2026-09-27 12:51:11 -05:00
Ethan O'Brien ee4ce5fed4 Remove chart migrate 2026-09-27 12:48:47 -05:00
Ethan O'Brien 9897a5d04a Cleanup compile warnings 2026-09-27 12:42:46 -05:00
Ethan O'Brien b1b70ab4c1 Don't send JP story IDs to the global client 2026-09-27 12:40:56 -05:00
Ethan O'Brien 99ba099d1b Add ability to force updates for outdated asset versions 2026-09-27 11:39:31 -05:00
Ethan O'Brien f1c8e96fbe Fix note count calculation 2026-09-26 22:19:16 -05:00
Ethan O'Brien abbfb6027a assets 2026-09-26 17:16:12 -05:00
Ethan O'Brien b38aec4ecf new assets 2026-09-26 13:49:19 -05:00
Ethan O'Brien 0b137effb5 Change some custom song handling stuff 2026-09-26 10:24:02 -05:00
Ethan O'Brien b08af4deb7 New asses 2026-09-26 09:01:08 -05:00
Ethan O'Brien bd6ae99888 sync webui 2026-09-25 22:48:55 -05:00
Ethan O'Brien 500159ecf5 New asset hashes 2026-09-25 22:00:24 -05:00
Ethan O'Brien 33eb7a4002 new webui 2026-09-25 20:13:39 -05:00
Ethan O'Brien 29bf71eb9b Redo story stuff 2026-09-25 12:06:46 -05:00
Ethan O'Brien 7ef418fc51 Fix updated titles not updating on reward recieve 2026-09-25 11:27:56 -05:00
Ethan O'Brien e387f1ff0e Restore beginner missions 2026-09-24 20:23:26 -05:00
Ethan O'Brien 125061863a Bump webui dependency 2026-09-24 19:17:41 -05:00
Ethan O'Brien dfc29c70e4 Add support for custom groups 2026-09-24 19:17:08 -05:00
Ethan O'Brien 8270662666 Keep one-point skill values valid under card nerf 2026-09-23 20:36:51 -05:00
Ethan O'Brien e258a8ca42 Add maintenance subcommand 2026-09-23 13:12:07 -05:00
Ethan O'Brien 3e58691c34 Fix custom card nerf not setting 2026-09-17 11:54:34 -05:00
Ethan O'Brien 2fe5ecd53e Fix issue 2026-09-16 22:17:31 -05:00
Ethan O'Brien 844a8f381b Add ability to nerf custom cards 2026-09-16 20:26:21 -05:00
Ethan O'Brien 49b2fbcb56 New asset hashes 2026-09-05 19:50:31 -05:00
Ethan O'Brien 78e1f1d1bb New asset hashes 2026-09-05 16:29:54 -05:00
Ethan O'Brien 2d9ea66ee3 Redo card syncing 2026-09-02 15:06:17 -05:00
Ethan O'Brien 59193d92f4 Ability to unbind nesica card 2026-09-02 10:09:24 -05:00
Ethan O'Brien b92acb7a00 Mac -> macOS 2026-08-28 22:32:16 -05:00
Ethan O'Brien 971aa32361 Add macos route and some other tests I guess 2026-08-28 21:40:26 -05:00
Ethan O'Brien 2ceff8ffc6 commit new webui 2026-08-28 19:45:37 -05:00
Ethan O'Brien ea39c12a9e Some design fixes in custom data handling 2026-08-28 19:43:56 -05:00
Ethan O'Brien 79ae274bfe Push webui 2026-08-28 17:25:16 -05:00
Ethan O'Brien fd8416b893 Sync masterdata 2026-08-28 17:09:22 -05:00
Ethan O'Brien 85f8b489d6 Fix a web announcement thingy 2026-08-28 17:08:35 -05:00
Ethan O'Brien f881a73dbd Fix some things with the new feature 2026-08-26 15:19:10 -05:00
Ethan O'Brien 9e5228e9f5 wip stuff (idk) 2026-08-26 08:21:29 -05:00
Ethan O'Brien 1d12a9415c Oops 2026-08-23 16:09:49 -05:00
Ethan O'Brien 4dcf73e5d4 New asset version I guess 2026-08-23 15:25:14 -05:00
Ethan O'Brien 06c9273151 New asset hashes I guess I hope nothing breaks 2026-08-17 15:22:51 -05:00
Ethan O'Brien d975b87799 Oh 2026-08-15 22:20:14 -05:00
Ethan O'Brien cb40d74c2c Announcements working 2026-08-12 17:21:52 -05:00
Ethan O'Brien 8f7346d09e New asset hashes 2026-08-11 21:47:28 -05:00
76 changed files with 10371 additions and 1354 deletions
+1
View File
@@ -12,6 +12,7 @@ ndk/
.DS_Store
custom_songs/
custom_cards/
/custom_3dmv/
# local-only trees — never commit (35GB between them)
/android/
Generated
+1 -1
View File
@@ -1085,7 +1085,7 @@ dependencies = [
[[package]]
name = "ew"
version = "1.1.0"
version = "2.0.0"
dependencies = [
"actix-multipart",
"actix-web",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "ew"
version = "1.1.0"
version = "2.0.0"
edition = "2024"
exclude = ["assets", "*.db"]
+1 -1
View File
@@ -1,7 +1,7 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=webui/index.html");
println!("cargo:rerun-if-changed=webui");
let target = std::env::var("TARGET").unwrap();
if target == "aarch64-linux-android" {
+3
View File
@@ -15,6 +15,8 @@ services:
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_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints)
#NERF_CUSTOM_CARDS: true # Cap custom card stats below the official maxima and skills at 80% of the official range (new uploads rejected, existing cards clamped in responses); needs ENABLE_CUSTOM_CARDS
#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
#PURGE: false # Purge dead user accounts on startup
#IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server)
@@ -30,6 +32,7 @@ services:
#EN_ANDROID_ASSET_HASH: ""
#EN_IOS_ASSET_HASH: ""
#WINDOWS_ASSET_HASH: "" # Windows asset hash (JP only; GL Windows is unused)
#FORCE_UPDATES: "true" # Force outdated clients to update
# Everything below is for the "Help" page
#ANDROID_GLOBAL: "link.to/patched/android/global.apk"
+10
View File
@@ -15,6 +15,10 @@ args=(
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
[ "${NERF_CUSTOM_CARDS:-}" = "true" ] && args+=(--nerf-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
[ "${ENABLE_ARCADE:-}" = "true" ] && args+=(--enable-arcade)
[ "${FORCE_UPDATES:-}" = "true" ] && args+=(--force-updates)
add_opt() {
local value="$1" flag="$2"
@@ -34,6 +38,12 @@ add_opt "${WINDOWS_ASSET_HASH:-}" --windows-asset-hash
# Server owner uid(s) for the permission system (comma-separated)
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.
add_opt "${IMAGE_ASSET_PATH:-}" --image-asset-path
add_opt "${MASTERDATA:-}" --masterdata
+4
View File
@@ -1,4 +1,8 @@
pub mod gree;
pub mod custom_song;
pub mod custom_card;
pub mod custom_group;
pub mod custom_3dmv;
pub mod permissions;
pub mod announcements;
pub mod arcade;
+267
View File
@@ -0,0 +1,267 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, object, JsonValue};
use crate::router::global;
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("announcements.db", setup_tables);
}
// 1 = notice, 2 = update, 3 = bug
pub const CATEGORIES: &[i64] = &[1, 2, 3];
pub const TYPES: &[&str] = &["news", "event", "gacha", "maintenance", "shop", "others"];
pub fn is_valid_category(category: i64) -> bool {
CATEGORIES.contains(&category)
}
pub fn is_valid_type(kind: &str) -> bool {
TYPES.contains(&kind)
}
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
banner BLOB,
updated INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 1,
published_at BIGINT NOT NULL,
created_by BIGINT NOT NULL,
created_at BIGINT NOT NULL
);
").unwrap();
}
pub enum Banner {
Keep,
Clear,
Set(Vec<u8>)
}
fn row_to_json(row: &rusqlite::Row) -> rusqlite::Result<JsonValue> {
Ok(object!{
id: row.get::<usize, i64>(0)?,
category: row.get::<usize, i64>(1)?,
type: row.get::<usize, String>(2)?,
title: row.get::<usize, String>(3)?,
body: row.get::<usize, String>(4)?,
has_banner: row.get::<usize, i64>(5)? != 0,
updated: row.get::<usize, i64>(6)? != 0,
visible: row.get::<usize, i64>(7)? != 0,
published_at: row.get::<usize, i64>(8)?,
created_by: row.get::<usize, i64>(9)?,
created_at: row.get::<usize, i64>(10)?
})
}
const COLUMNS: &str = "id, category, type, title, body, banner IS NOT NULL, updated, visible, published_at, created_by, created_at";
fn query(where_clause: &str, args: &[&dyn rusqlite::ToSql]) -> JsonValue {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let sql = format!("SELECT {COLUMNS} FROM announcements {where_clause} ORDER BY published_at DESC, id DESC");
let Ok(mut stmt) = conn.prepare(&sql) else {
return array![];
};
let Ok(mapped) = stmt.query_map(args, |row| row_to_json(row)) else {
return array![];
};
let mut rv = array![];
for row in mapped.flatten() {
rv.push(row).unwrap();
}
rv
}
pub fn list_category(category: i64) -> JsonValue {
query("WHERE visible=1 AND category=?1", params!(category))
}
pub fn get_all() -> JsonValue {
query("", params!())
}
pub fn get(id: i64) -> Option<JsonValue> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let sql = format!("SELECT {COLUMNS} FROM announcements WHERE id=?1");
conn.query_row(&sql, params!(id), |row| row_to_json(row)).ok()
}
pub fn get_banner(id: i64) -> Option<Vec<u8>> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
}
pub fn get_public_banner(id: i64) -> Option<Vec<u8>> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT banner FROM announcements WHERE id=?1 AND visible=1 AND banner IS NOT NULL", params!(id), |row| row.get::<usize, Vec<u8>>(0)).ok()
}
pub fn visible_ids(category: Option<i64>) -> Vec<i64> {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let (sql, args): (&str, Vec<&dyn rusqlite::ToSql>) = match &category {
Some(c) => ("SELECT id FROM announcements WHERE visible=1 AND category=?1", vec![c]),
None => ("SELECT id FROM announcements WHERE visible=1", vec![])
};
let Ok(mut stmt) = conn.prepare(sql) else {
return Vec::new();
};
let Ok(mapped) = stmt.query_map(args.as_slice(), |row| row.get::<usize, i64>(0)) else {
return Vec::new();
};
mapped.flatten().collect()
}
pub fn latest_published_at() -> i64 {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.query_row("SELECT MAX(published_at) FROM announcements WHERE visible=1", params!(), |row| row.get::<usize, i64>(0)).unwrap_or(0)
}
pub fn create(category: i64, kind: &str, title: &str, body: &str, banner: Option<Vec<u8>>, updated: bool, visible: bool, published_at: i64, created_by: i64) -> i64 {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute(
"INSERT INTO announcements (category, type, title, body, banner, updated, visible, published_at, created_by, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
params!(category, kind, title, body, banner, updated as i64, visible as i64, published_at, created_by, global::timestamp() as i64)
).unwrap();
conn.last_insert_rowid()
}
pub fn update(id: i64, category: i64, kind: &str, title: &str, body: &str, banner: Banner, updated: bool, visible: bool, published_at: i64) {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute(
"UPDATE announcements SET category=?2, type=?3, title=?4, body=?5, updated=?6, visible=?7, published_at=?8 WHERE id=?1",
params!(id, category, kind, title, body, updated as i64, visible as i64, published_at)
).unwrap();
match banner {
Banner::Keep => {},
Banner::Clear => { conn.execute("UPDATE announcements SET banner=NULL WHERE id=?1", params!(id)).unwrap(); },
Banner::Set(bytes) => { conn.execute("UPDATE announcements SET banner=?2 WHERE id=?1", params!(id, bytes)).unwrap(); }
}
}
pub fn delete(id: i64) {
DATABASE.lock_and_exec("DELETE FROM announcements WHERE id=?1", params!(id));
}
/// more tests??? This is probably a good thing but man haha
#[cfg(test)]
mod tests {
use super::*;
fn wipe() {
DATABASE.lock_and_exec("DELETE FROM announcements", params!());
}
#[test]
fn create_list_and_category_filter() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let a = create(1, "news", "First", "<p>body</p>", None, false, true, 1000, 42);
let b = create(1, "event", "Second", "body", Some(vec![1, 2, 3]), true, true, 2000, 42);
let c = create(2, "gacha", "Other tab", "body", None, false, true, 1500, 42);
let hidden = create(1, "news", "Draft", "body", None, false, false, 3000, 42);
// One tab, visible only, newest first
let cat1 = list_category(1);
assert_eq!(cat1.len(), 2);
assert_eq!(cat1[0]["id"].as_i64(), Some(b));
assert_eq!(cat1[1]["id"].as_i64(), Some(a));
assert!(cat1.members().all(|row| row["id"].as_i64() != Some(hidden)));
// has_banner reflects the blob without carrying it
assert_eq!(cat1[0]["has_banner"].as_bool(), Some(true));
assert_eq!(cat1[1]["has_banner"].as_bool(), Some(false));
assert_eq!(get_banner(b), Some(vec![1, 2, 3]));
assert_eq!(get_banner(a), None);
// The other tab is untouched, the admin view sees the draft too
assert_eq!(list_category(2).len(), 1);
assert_eq!(list_category(2)[0]["id"].as_i64(), Some(c));
assert_eq!(get_all().len(), 4);
assert_eq!(latest_published_at(), 2000);
let mut visible = visible_ids(None);
visible.sort();
assert_eq!(visible, vec![a, b, c]);
let mut cat1_ids = visible_ids(Some(1));
cat1_ids.sort();
assert_eq!(cat1_ids, vec![a, b]);
wipe();
}
#[test]
fn update_rewrites_and_banner_transitions() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let id = create(1, "news", "Title", "body", Some(vec![9]), false, true, 1000, 7);
update(id, 3, "maintenance", "New title", "new body", Banner::Keep, true, true, 5000);
let row = get(id).unwrap();
assert_eq!(row["category"].as_i64(), Some(3));
assert_eq!(row["type"].as_str(), Some("maintenance"));
assert_eq!(row["title"].as_str(), Some("New title"));
assert_eq!(row["updated"].as_bool(), Some(true));
assert_eq!(row["published_at"].as_i64(), Some(5000));
// Keep left the blob in place
assert_eq!(get_banner(id), Some(vec![9]));
update(id, 3, "maintenance", "New title", "new body", Banner::Set(vec![4, 5]), true, true, 5000);
assert_eq!(get_banner(id), Some(vec![4, 5]));
update(id, 3, "maintenance", "New title", "new body", Banner::Clear, true, false, 5000);
assert_eq!(get_banner(id), None);
assert_eq!(get(id).unwrap()["visible"].as_bool(), Some(false));
delete(id);
assert!(get(id).is_none());
wipe();
}
// Ids are sequential, so the player-facing banner route is pollable: a
// draft's banner must be reachable by the admin lookup and by nothing else
#[test]
fn a_drafts_banner_is_admin_only() {
let _lock = crate::runtime::lock_test_data_path();
wipe();
let published = create(1, "news", "Live", "body", Some(vec![1, 2]), false, true, 1000, 42);
let draft = create(1, "news", "Unannounced", "body", Some(vec![7, 7]), false, false, 2000, 42);
assert_eq!(get_banner(draft), Some(vec![7, 7]));
assert_eq!(get_public_banner(draft), None);
assert_eq!(get_public_banner(published), Some(vec![1, 2]));
// Publishing it makes the banner reachable, hiding it again takes it back
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, true, 2000);
assert_eq!(get_public_banner(draft), Some(vec![7, 7]));
update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, false, 2000);
assert_eq!(get_public_banner(draft), None);
wipe();
}
#[test]
fn vocabulary_is_wellformed() {
for category in CATEGORIES {
assert!(is_valid_category(*category));
}
assert!(!is_valid_category(0));
assert!(!is_valid_category(4));
for kind in TYPES {
assert!(is_valid_type(kind));
}
assert!(!is_valid_type("explosion"));
}
}
+467
View File
@@ -0,0 +1,467 @@
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.
//
// Which account a card names lives in userdata.db (userdata::user::migration,
// the `cards` table): linking a card is a plain account feature and works with
// this module off. What lives here is only the cabinet side of a card:
// `card_sessions` is the "which cabinet is this card sitting at" record -
// `last_machine_id` / `last_session` attribute a card account's play to the
// machine that most recently ran a session for it - and `session_until` is the
// one that costs money: the moment the credit that /api/arcade/session took
// stops buying LP-free lives. Before it, a card account plays as a cabinet does;
// after it, the same account is an ordinary phone account again. It is a stamp
// rather than a flag so that a cabinet that loses power mid-credit expires on
// its own with nothing to clean up, and so an operator can size the window with
// --arcade-session-ttl. Re-linking a card clears its row (card_relinked).
//
// `plays` is the bookkeeping ledger: one row per arcade song, cleared or not.
// `cleared` is 0 for a song whose life gauge emptied: the client plays it out and
// 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 card_sessions (
card_id TEXT NOT NULL PRIMARY KEY,
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();
// Databases from before the card mapping moved to userdata.db carry a
// `cards` table here. Its mappings move over once, its windows become
// card_sessions rows, and the table goes.
if conn.prepare("SELECT user_id FROM cards LIMIT 1;").is_ok() {
println!("Moving arcade card mappings to userdata");
let has_sessions = conn.prepare("SELECT session_until FROM cards LIMIT 1;").is_ok();
let query = if has_sessions {
"SELECT card_id, user_id, created, last_machine_id, last_session, session_until FROM cards"
} else {
"SELECT card_id, user_id, created, '', 0, 0 FROM cards"
};
let mut stmt = conn.prepare(query).unwrap();
let rows: Vec<(String, i64, i64, String, i64, i64)> = stmt.query_map([], |row| Ok((
row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?
))).unwrap().flatten().collect();
drop(stmt);
for (card_id, user_id, created, last_machine_id, last_session, session_until) in rows {
crate::router::userdata::user::migration::import_card(&card_id, user_id, created);
if !last_machine_id.is_empty() || session_until != 0 {
conn.execute(
"INSERT OR REPLACE INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)",
params!(card_id, last_machine_id, last_session, session_until)
).unwrap();
}
}
conn.execute("DROP TABLE cards;", []).unwrap();
}
}
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("DELETE FROM card_sessions WHERE last_machine_id=?1", params!(machine_id));
DATABASE.lock_and_exec("DELETE FROM machines WHERE machine_id=?1", params!(machine_id));
}
// 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
}
// 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(
"INSERT INTO card_sessions (card_id, last_machine_id, last_session, session_until) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(card_id) DO UPDATE SET last_machine_id=?2, last_session=?3, session_until=?4",
params!(card_id, machine_id, global::timestamp() as i64, until)
);
}
// A card that changed hands takes nothing of the previous holder's credit with it
pub fn clear_card_session(card_id: &str) {
DATABASE.lock_and_exec("DELETE FROM card_sessions WHERE card_id=?1", params!(card_id));
}
fn placeholders(count: usize) -> String {
(1..=count).map(|i| format!("?{}", i)).collect::<Vec<_>>().join(", ")
}
// Of these cards (an account's, userdata::user::migration::cards_of_account),
// the one whose cabinet session is still open at `now`, as (card id, when the
// session started). None when none of them is at a cabinet - which is every
// phone account, and every card between credits.
pub fn live_card_session(cards: &[String], now: i64) -> Option<(String, i64)> {
if cards.is_empty() {
return None;
}
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
let sql = format!(
"SELECT card_id, last_session FROM card_sessions WHERE card_id IN ({}) AND session_until>?{} ORDER BY session_until DESC LIMIT 1",
placeholders(cards.len()), cards.len() + 1
);
let mut stmt = conn.prepare(&sql).ok()?;
let mut args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect();
args.push(&now);
stmt.query_row(args.as_slice(), |row| Ok((row.get::<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 card_sessions 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 card_sessions 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 any of these cards. None
// when none of them has ever been at a cabinet
pub fn last_machine_of_cards(cards: &[String]) -> Option<String> {
if cards.is_empty() {
return None;
}
let conn = rusqlite::Connection::open(DATABASE.get_path()).ok()?;
let sql = format!(
"SELECT last_machine_id FROM card_sessions WHERE card_id IN ({}) AND last_machine_id<>'' ORDER BY last_session DESC LIMIT 1",
placeholders(cards.len())
);
let mut stmt = conn.prepare(&sql).ok()?;
let args: Vec<&dyn rusqlite::ToSql> = cards.iter().map(|c| c as &dyn rusqlite::ToSql).collect();
let machine_id: String = stmt.query_row(args.as_slice(), |row| row.get(0)).ok()?;
if machine_id.is_empty() {
return None;
}
Some(machine_id)
}
pub fn 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's cabinet record follows the card: it is attributed to the cabinet
// the card last sat at, a re-link clears it, and it dies with the machine
#[test]
fn a_cards_cabinet_record_follows_the_card() {
let _lock = crate::runtime::lock_test_data_path();
let machine = generate_machine_id();
insert_machine(&machine, "Cabinet 2", 333_333_333_333_333, 444_444_444_444_444);
let cards = vec!["0123456789012345".to_string()];
let card = cards[0].as_str();
// Never at a cabinet yet
assert!(last_machine_of_cards(&cards).is_none());
assert!(live_card_session(&[], 0).is_none());
let now = global::timestamp() as i64;
open_card_session(card, &machine, now + 60);
assert_eq!(last_machine_of_cards(&cards).as_deref(), Some(machine.as_str()));
// A re-link forgets the cabinet and the window the previous account's credit paid for
clear_card_session(card);
assert!(last_machine_of_cards(&cards).is_none());
assert!(live_card_session(&cards, now).is_none(), "a re-link carried the previous account's credit over");
assert_eq!(play_count(&machine), 0);
insert_play(&machine, 666_666_666_666_666, 1100101, 4, 654_321, 3, true);
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 and its card records with it
open_card_session(card, &machine, now + 60);
delete_machine(&machine);
assert_eq!(play_count(&machine), 0);
assert!(last_machine_of_cards(&cards).is_none());
assert!(live_card_session(&cards, now).is_none(), "a retired cabinet left a credit open");
}
// The credit a card paid for is a window on its own row: open until it is
// not, pushed forward but never backward, and never shared with another card
#[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 cards = vec!["1212121212121212".to_string()];
let card = cards[0].as_str();
let now = global::timestamp() as i64;
// A card with no session behind it is not a cabinet session
assert!(live_card_session(&cards, now).is_none());
open_card_session(card, &machine, now + 600);
let (open_card, opened) = live_card_session(&cards, now).expect("the credit did not open a window");
assert_eq!(open_card, card);
assert!(opened <= now && opened >= now - 5, "the session start was not stamped: {} vs {}", opened, now);
// The window closes by itself, with nothing to sweep
assert!(live_card_session(&cards, now + 599).is_some());
assert!(live_card_session(&cards, now + 600).is_none(), "the window outlived its own expiry");
assert!(live_card_session(&cards, now + 601).is_none());
// A live inside it pushes it forward, and only forward
extend_card_session(card, now + 1200);
assert!(live_card_session(&cards, now + 900).is_some());
extend_card_session(card, now + 300);
assert!(live_card_session(&cards, now + 900).is_some(), "an extension moved the window backwards");
// Another card is untouched by any of it
let other = vec!["3434343434343434".to_string()];
assert!(live_card_session(&other, now).is_none());
delete_machine(&machine);
}
}
+263
View File
@@ -0,0 +1,263 @@
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
}
// The row and the high-water mark are one write: a failure between them leaves an
// MV whose id the next upload would reissue, and the failure has to reach the
// uploader as an error rather than panic the worker (lock_and_exec unwraps)
pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute(
"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)
)?;
conn.execute("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))?;
Ok(())
})
}
// 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.
//
// Returns Result and never folds an error into "not referenced": the caller
// unlinks on false, and a read that failed (SQLITE_BUSY under a concurrent
// write, a corrupt page) says nothing about whether the blob is live. The
// startup sweep is deliberately fail-closed; this is its online half
pub fn blob_in_use(md5: &str) -> Result<bool, rusqlite::Error> {
let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5)))?;
for blob in rows.members() {
let Ok(mv) = jzon::parse(&blob.to_string()) else {
// An unparseable row could reference anything - assume it does
return Ok(true);
};
if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) {
return Ok(true);
}
}
Ok(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 {
// A read failure here means "cannot prove this blob is served", which the
// route turns into a 404 - the client re-requests. Unlike the GC, guessing
// wrong in this direction costs nothing permanent
blob_in_use(md5).unwrap_or(false)
}
// Every MV this account uploaded, for the account-deletion purge
pub fn mv_ids_for_owner(owner_id: i64) -> Vec<i64> {
let rows = DATABASE.lock_and_select_all("SELECT mv_id FROM mvs WHERE owner_id=?1 ORDER BY mv_id", params!(owner_id)).unwrap_or(array![]);
rows.members().filter_map(|id| id.as_i64()).collect()
}
// The stored bytes an MV's files account for. Blobs are shared, so two MVs that
// carry the same model both count it: the quota is "what this account asked the
// server to store", not a dedup-aware disk figure
pub fn mv_bytes(files: &JsonValue) -> i64 {
files.members().map(|file| file["size"].as_i64().unwrap_or(0)).sum()
}
// What this account's MVs already occupy, optionally ignoring one (the MV being
// replaced by an in-place edit, whose new size is counted instead)
pub fn owner_bytes(owner_id: i64, excluding_mv_id: i64) -> i64 {
let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![]));
rows.members()
.filter(|mv| mv["mv_id"].as_i64() != Some(excluding_mv_id))
.map(|mv| mv_bytes(&mv["files"]))
.sum()
}
// The on-disk database file, so a test can force the read errors the fail-closed
// GC paths are built for
#[cfg(test)]
pub fn test_db_path() -> String {
DATABASE.get_path().to_string()
}
+113 -31
View File
@@ -87,20 +87,29 @@ pub fn next_character_id() -> i64 {
std::cmp::max(std::cmp::max(issued, max), FIRST_CHARACTER_ID - 1) + 1
}
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) {
DATABASE.lock_and_exec(
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
);
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id));
// The row and the high-water mark are one write: a failure between them leaves a
// card whose id the next upload would reissue, and the failure has to reach the
// uploader as an error rather than panic the worker (lock_and_exec unwraps)
pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute(
"INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64)
)?;
conn.execute("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id))?;
Ok(())
})
}
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) {
DATABASE.lock_and_exec(
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
);
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id));
pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute(
"INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)",
params!(master_character_id, owner_id, jzon::stringify(character.clone()))
)?;
conn.execute("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id))?;
Ok(())
})
}
// The catalog blob only. The owner and the published/obtainable flags live in
@@ -173,6 +182,47 @@ pub fn has_character(master_character_id: i64) -> bool {
DATABASE.lock_and_select("SELECT master_character_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).is_ok()
}
// Every card / character this account uploaded, for the account-deletion purge
pub fn card_ids_for_owner(owner_id: i64) -> Vec<i64> {
DATABASE.lock_and_select_all("SELECT master_card_id FROM cards WHERE owner_id=?1 ORDER BY master_card_id", params!(owner_id))
.unwrap_or(array![]).members().filter_map(|id| id.as_i64()).collect()
}
pub fn character_ids_for_owner(owner_id: i64) -> Vec<i64> {
DATABASE.lock_and_select_all("SELECT master_character_id FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(owner_id))
.unwrap_or(array![]).members().filter_map(|id| id.as_i64()).collect()
}
// The stored bytes one card / character accounts for: every derived art png
// plus, for a character, its voiceline oggs
pub fn card_bytes(card: &JsonValue) -> i64 {
card["art"].members().map(|art| art["size"].as_i64().unwrap_or(0)).sum()
}
pub fn character_bytes(character: &JsonValue) -> i64 {
let art: i64 = character["art"].members().map(|art| art["size"].as_i64().unwrap_or(0)).sum();
let voice: i64 = character["voice"].members().map(|line| line["size"].as_i64().unwrap_or(0)).sum();
art + voice
}
// What this account's uploads already occupy. Cards and characters share one
// quota (they share one storage root), each optionally ignoring the entity being
// replaced by an in-place edit, whose new size is counted instead
pub fn owner_bytes(owner_id: i64, excluding_card_id: i64, excluding_character_id: i64) -> i64 {
let mut total = 0;
for blob in parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![])).members() {
if blob["master_card_id"].as_i64() != Some(excluding_card_id) {
total += card_bytes(blob);
}
}
for blob in parse_blobs(DATABASE.lock_and_select_all("SELECT character FROM characters WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![])).members() {
if blob["master_character_id"].as_i64() != Some(excluding_character_id) {
total += character_bytes(blob);
}
}
total
}
pub fn card_count_for_owner(owner_id: i64) -> i64 {
DATABASE.lock_and_select_type::<i64>("SELECT COUNT(*) FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(0)
}
@@ -300,7 +350,10 @@ pub fn get_browse_cards() -> JsonValue {
// considered, and ids are never reused, so official (or imported) cards can't
// come back from this and a wipe is final. A card that's merely unpublished
// still has its row - only genuinely deleted ids are returned
pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue {
// None = the catalog could not be read (or is entirely empty while players still hold
// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly
// like one where every id is dead, and get_acc saves the pruned userdata.
pub fn dead_card_ids(candidates: &JsonValue) -> Option<JsonValue> {
let mut ids: Vec<i64> = Vec::new();
for id in candidates.members() {
let Some(id) = id.as_i64() else { continue; };
@@ -309,17 +362,23 @@ pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue {
}
}
if ids.is_empty() {
return array![];
return Some(array![]);
}
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).unwrap_or(array![]);
let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).ok()?;
if alive.is_empty() {
let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM cards", params!()).ok()?;
if total == 0 {
return None;
}
}
let mut rv = array![];
for id in ids {
if !alive.contains(id) {
rv.push(id).unwrap();
}
}
rv
Some(rv)
}
// The published + obtainable pool the custom gacha banner draws from, per
@@ -410,10 +469,10 @@ mod tests {
let first = next_card_id();
assert!(first >= FIRST_CARD_ID);
assert_ne!(first % 10000, 0);
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false);
insert_card(first, 1001, 3001, &card_blob(first, 1), false, false).unwrap();
let second = next_card_id();
assert_eq!(second, first + 1);
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false);
insert_card(second, 1001, 3001, &card_blob(second, 1), false, false).unwrap();
delete_card(second);
assert_eq!(get_card(second), None);
// The high-water mark survives the delete, so the id is retired
@@ -421,7 +480,7 @@ mod tests {
let character = next_character_id();
assert!(character >= FIRST_CHARACTER_ID);
insert_character(character, 3001, &object!{ "master_character_id": character });
insert_character(character, 3001, &object!{ "master_character_id": character }).unwrap();
assert_eq!(next_character_id(), character + 1);
delete_character(character);
assert_eq!(next_character_id(), character + 1);
@@ -440,13 +499,13 @@ mod tests {
wipe(3004);
let character = next_character_id();
insert_character(character, 3003, &object!{ "master_character_id": character });
insert_character(character, 3003, &object!{ "master_character_id": character }).unwrap();
let published = next_card_id();
let mut blob = card_blob(published, 3);
blob["master_character_id"] = character.into();
insert_card(published, character, 3003, &blob, true, true);
insert_card(published, character, 3003, &blob, true, true).unwrap();
let draft = next_card_id();
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false);
insert_card(draft, character, 3003, &card_blob(draft, 1), false, false).unwrap();
let owner_view = get_cards_for_user(3003, &[]);
assert_eq!(owner_view.len(), 2);
@@ -493,13 +552,13 @@ mod tests {
wipe(3005);
let r1 = next_card_id();
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true);
insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true).unwrap();
let r3 = next_card_id();
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true);
insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true).unwrap();
let unpublished = next_card_id();
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true);
insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true).unwrap();
let unobtainable = next_card_id();
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false);
insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false).unwrap();
assert_eq!(obtainable_card_ids(1), vec![r1]);
assert_eq!(obtainable_card_ids(3), vec![r3]);
@@ -516,7 +575,7 @@ mod tests {
wipe(3007);
let id = next_card_id();
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false);
insert_card(id, 1001, 3007, &card_blob(id, 1), true, false).unwrap();
assert_eq!(find_asset_by_md5(&format!("{:032x}", id)), Some(format!("{}/c_00.png", id)));
assert_eq!(find_asset_by_md5("00000000000000000000000000000000"), None);
@@ -524,7 +583,7 @@ mod tests {
insert_character(character, 3007, &object!{
"master_character_id": character,
"art": [{ "kind": "icon", "md5": "aabbccddeeff00112233445566778899", "size": 1 }]
});
}).unwrap();
assert_eq!(
find_asset_by_md5("aabbccddeeff00112233445566778899"),
Some(format!("characters/{}/icon.png", character))
@@ -541,15 +600,38 @@ mod tests {
wipe(3008);
let alive = next_card_id();
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false);
insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false).unwrap();
let dead = next_card_id();
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false);
insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false).unwrap();
delete_card(dead);
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]);
let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]).unwrap();
assert_eq!(dead_ids.len(), 1);
assert_eq!(dead_ids[0].as_i64(), Some(dead));
wipe(3008);
}
// An unreadable or blank catalog must never read as "every custom card is
// dead": get_acc prunes on that answer and saves the pruned userdata
#[test]
fn dead_ids_are_unknown_when_the_catalog_cannot_be_read() {
let _lock = crate::runtime::lock_test_data_path();
wipe(3009);
let alive = next_card_id();
insert_card(alive, 1001, 3009, &card_blob(alive, 1), false, false).unwrap();
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
conn.execute("ALTER TABLE cards RENAME TO cards_hidden", ()).unwrap();
let unreadable = dead_card_ids(&array![alive]);
conn.execute("CREATE TABLE cards (master_card_id BIGINT NOT NULL PRIMARY KEY, master_character_id BIGINT NOT NULL, owner_id BIGINT NOT NULL, card TEXT NOT NULL, rarity INT NOT NULL DEFAULT 1, published INT NOT NULL DEFAULT 0, obtainable INT NOT NULL DEFAULT 0)", ()).unwrap();
let blank = dead_card_ids(&array![alive]);
conn.execute("DROP TABLE cards", ()).unwrap();
conn.execute("ALTER TABLE cards_hidden RENAME TO cards", ()).unwrap();
assert!(unreadable.is_none(), "an unreadable catalog reported dead cards");
assert!(blank.is_none(), "a blank catalog reported every card dead");
assert_eq!(dead_card_ids(&array![alive]).unwrap().len(), 0);
wipe(3009);
}
}
+76
View File
@@ -0,0 +1,76 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, object, JsonValue};
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("custom_groups.db", setup_tables);
}
pub const FIRST_ID: i64 = 10_000;
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS custom_groups_unique_name ON groups(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS logos (group_id INTEGER PRIMARY KEY, md5 TEXT NOT NULL, size INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sequence (id INTEGER PRIMARY KEY CHECK (id=1), last_id INTEGER NOT NULL);").unwrap();
}
pub fn list() -> JsonValue {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let Ok(mut stmt) = conn.prepare("SELECT id, name, name_en, COALESCE(md5, ''), COALESCE(size, 0) FROM groups LEFT JOIN logos ON group_id=id ORDER BY id") else { return array![]; };
let Ok(rows) = stmt.query_map(params![], |row| Ok(object! {
"id": row.get::<_, i64>(0)?,
"name": row.get::<_, String>(1)?,
"name_en": row.get::<_, String>(2)?,
"logo_md5": row.get::<_, String>(3)?,
"logo_size": row.get::<_, i64>(4)?
})) else { return array![]; };
let mut result = array![];
for row in rows.flatten() { result.push(row).unwrap(); }
result
}
pub fn set_logo(id: i64, md5: &str, size: i64) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute("INSERT INTO logos (group_id, md5, size) VALUES (?1, ?2, ?3) ON CONFLICT(group_id) DO UPDATE SET md5=excluded.md5, size=excluded.size", params!(id, md5, size))?;
Ok(())
})
}
pub fn has_logo(md5: &str) -> bool {
DATABASE.lock_and_select("SELECT group_id FROM logos WHERE md5=?1", params!(md5)).is_ok()
}
pub fn exists(id: i64) -> bool {
if id < FIRST_ID { return false; }
DATABASE.lock_and_select("SELECT id FROM groups WHERE id=?1", params!(id)).is_ok()
}
pub fn create(name: &str, name_en: &str) -> Result<i64, rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
let last: i64 = conn.query_row("SELECT last_id FROM sequence WHERE id=1", [], |row| row.get(0)).unwrap_or(FIRST_ID - 1);
let id = last + 1;
conn.execute("INSERT INTO groups (id, name, name_en) VALUES (?1, ?2, ?3)", params!(id, name, name_en))?;
conn.execute("INSERT INTO sequence (id, last_id) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET last_id=?1", params!(id))?;
Ok(id)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_logo_storage_to_existing_group_database() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL);
INSERT INTO groups VALUES (10000, 'Team A', 'Team A');").unwrap();
setup_tables(&conn);
setup_tables(&conn);
assert_eq!(conn.query_row("SELECT name FROM groups WHERE id=10000", [], |row| row.get::<_, String>(0)).unwrap(), "Team A");
conn.execute("INSERT INTO logos VALUES (10000, 'test-hash', 42)", []).unwrap();
}
}
+143 -20
View File
@@ -107,10 +107,16 @@ pub fn next_music_id() -> i64 {
std::cmp::max(std::cmp::max(issued, max), FIRST_MUSIC_ID - 1) + 1
}
pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) {
DATABASE.lock_and_exec("INSERT INTO songs (music_id, owner_id, song, visibility, downloads_disabled) VALUES (?1, ?2, ?3, ?4, ?5)", params!(music_id, owner_id, jzon::stringify(song.clone()), visibility, downloads_disabled as i64));
DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_music_id=?1", params!(music_id));
set_shared_users(music_id, shared_with);
// The row, the high-water mark and the shared-user list are one write: a failure
// between them would leave a song whose id the next upload reissues, and the
// failure itself must reach the uploader as an error rather than panic the worker
// (lock_and_exec unwraps, lock_and_transact returns)
pub fn insert_song(music_id: i64, owner_id: i64, song: &JsonValue, visibility: &str, shared_with: &JsonValue, downloads_disabled: bool) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute("INSERT INTO songs (music_id, owner_id, song, visibility, downloads_disabled) VALUES (?1, ?2, ?3, ?4, ?5)", params!(music_id, owner_id, jzon::stringify(song.clone()), visibility, downloads_disabled as i64))?;
conn.execute("INSERT INTO revision (id, revision, last_music_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_music_id=?1", params!(music_id))?;
write_shared_users(conn, music_id, shared_with)
})
}
// Replace an existing song's catalog blob in place. The owner, visibility,
@@ -119,9 +125,12 @@ pub fn update_song(music_id: i64, song: &JsonValue) {
DATABASE.lock_and_exec("UPDATE songs SET song=?1 WHERE music_id=?2", params!(jzon::stringify(song.clone()), music_id));
}
pub fn delete_song(music_id: i64) {
DATABASE.lock_and_exec("DELETE FROM songs WHERE music_id=?1", params!(music_id));
DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id));
pub fn delete_song(music_id: i64) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute("DELETE FROM songs WHERE music_id=?1", params!(music_id))?;
conn.execute("DELETE FROM shared_users WHERE music_id=?1", params!(music_id))?;
Ok(())
})
}
pub fn get_song(music_id: i64) -> Option<JsonValue> {
@@ -133,9 +142,11 @@ pub fn get_song_owner(music_id: i64) -> Option<i64> {
DATABASE.lock_and_select("SELECT owner_id FROM songs WHERE music_id=?1", params!(music_id)).ok()?.parse::<i64>().ok()
}
pub fn set_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) {
DATABASE.lock_and_exec("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id));
set_shared_users(music_id, shared_with);
pub fn set_visibility(music_id: i64, visibility: &str, shared_with: &JsonValue) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute("UPDATE songs SET visibility=?1 WHERE music_id=?2", params!(visibility, music_id))?;
write_shared_users(conn, music_id, shared_with)
})
}
pub fn set_downloads_disabled(music_id: i64, downloads_disabled: bool) {
@@ -146,11 +157,15 @@ fn get_downloads_disabled(music_id: i64) -> bool {
DATABASE.lock_and_select("SELECT downloads_disabled FROM songs WHERE music_id=?1", params!(music_id)).unwrap_or_default() == "1"
}
fn set_shared_users(music_id: i64, shared_with: &JsonValue) {
DATABASE.lock_and_exec("DELETE FROM shared_users WHERE music_id=?1", params!(music_id));
// Replace-the-whole-list, inside the caller's transaction: a half-written list is a
// song shared with the wrong people
fn write_shared_users(conn: &rusqlite::Connection, music_id: i64, shared_with: &JsonValue) -> Result<(), rusqlite::Error> {
conn.execute("DELETE FROM shared_users WHERE music_id=?1", params!(music_id))?;
for id in shared_with.members() {
DATABASE.lock_and_exec("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id.as_i64().unwrap()));
let Some(id) = id.as_i64() else { continue; };
conn.execute("INSERT OR IGNORE INTO shared_users (music_id, user_id) VALUES (?1, ?2)", params!(music_id, id))?;
}
Ok(())
}
fn get_visibility(music_id: i64) -> String {
@@ -257,6 +272,75 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
Some(if english && !name_en.is_empty() { name_en } else { name })
}
// Whether this viewer may fetch the song's per-id asset files (jacket, blur,
// chart JSON). Exactly the catalog's visibility rule: public to everyone, private
// to its owner, shared to its owner and the shared-user list. Anonymous viewers
// (no webui session) are only ever shown public songs.
//
// The /data/{md5} route stays sessionless on purpose - a 128-bit content hash IS
// the capability there - but /assets/{music_id}/{file} is addressed by a
// sequential id, so it needs the real rule
pub fn asset_visible(music_id: i64, viewer: Option<i64>) -> bool {
let Some(owner) = get_song_owner(music_id) else {
return false;
};
let viewer = viewer.unwrap_or(0);
if owner == viewer {
return true;
}
match get_visibility(music_id).as_str() {
"public" => true,
"shared" => get_shared_users(music_id).contains(viewer),
_ => false
}
}
// Every song this account uploaded, for the account-deletion purge
pub fn music_ids_by_owner(owner_id: i64) -> Vec<i64> {
DATABASE.lock_and_select_all("SELECT music_id FROM songs WHERE owner_id=?1 ORDER BY music_id", params!(owner_id))
.unwrap_or(array![])
.members().filter_map(|id| id.as_i64()).collect()
}
// The served bytes one song accounts for: both jackets, every chart and both
// audio cues, straight out of the catalog blob that quotes them to the client.
// The original upload artifacts kept under original/ are NOT counted (the
// catalog does not record their sizes); the per-account cap is set with that
// roughly-2x on-disk factor in mind
pub fn song_bytes(song: &JsonValue) -> i64 {
let mut total = song["jacket_size"].as_i64().unwrap_or(0) + song["jacket_blur_size"].as_i64().unwrap_or(0);
for level in song["levels"].members() {
total += level["size"].as_i64().unwrap_or(0);
}
for key in ["play", "select"] {
total += song["sound"][key]["size"].as_i64().unwrap_or(0);
}
total
}
// What this account's songs already occupy, optionally ignoring one song (the
// one being replaced by an in-place edit, whose new size is counted instead)
pub fn owner_bytes(owner_id: i64, excluding_music_id: i64) -> i64 {
let songs = DATABASE.lock_and_select_all("SELECT song FROM songs WHERE owner_id=?1", params!(owner_id)).unwrap_or(array![]);
let mut total = 0;
for blob in songs.members() {
let Ok(song) = jzon::parse(&blob.to_string()) else { continue; };
if song["music_id"].as_i64() == Some(excluding_music_id) {
continue;
}
total += song_bytes(&song);
}
total
}
// 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 {
DATABASE.lock_and_select_all("
SELECT music_id FROM songs
@@ -281,7 +365,10 @@ pub fn non_public_music_ids_for(user_id: i64) -> JsonValue {
// range is ever considered, so official songs can't come back from this. A song
// that's merely private/shared still has its row - only genuinely deleted ids
// (which are never reused) are returned
pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
// None = the catalog could not be read (or is entirely empty while players still hold
// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly
// like one where every id is dead, and get_acc saves the pruned userdata.
pub fn dead_music_ids(candidates: &JsonValue) -> Option<JsonValue> {
let mut ids: Vec<i64> = Vec::new();
for id in candidates.members() {
let Some(id) = id.as_i64() else { continue; };
@@ -290,17 +377,23 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
}
}
if ids.is_empty() {
return array![];
return Some(array![]);
}
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).unwrap_or(array![]);
let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).ok()?;
if alive.is_empty() {
let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM songs", params!()).ok()?;
if total == 0 {
return None;
}
}
let mut rv = array![];
for id in ids {
if !alive.contains(id) {
rv.push(id).unwrap();
}
}
rv
Some(rv)
}
// Every stored catalog blob, unparsed and unfiltered by visibility. Only the
@@ -311,9 +404,32 @@ pub fn all_song_blobs() -> Option<JsonValue> {
DATABASE.lock_and_select_all("SELECT song FROM songs ORDER BY music_id", params!()).ok()
}
// Audio files are content-addressed and may be shared between songs
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool {
DATABASE.lock_and_select("SELECT music_id FROM songs WHERE music_id!=?1 AND song LIKE ?2", params!(ignored_music_id, format!("%{}%", md5))).is_ok()
// Audio files are content-addressed and may be shared between songs, so an ogg is
// only unlinkable once no other song's play/select cue names it. The LIKE scan finds
// candidate rows cheaply and the parsed cues confirm the hit (a substring coincidence
// elsewhere in the blob is not a reference), exactly like custom_3dmv::blob_in_use.
//
// Returns Result and NEVER folds an error into "false": the caller unlinks on false,
// and a read that failed (SQLITE_BUSY under a concurrent write, a corrupt page) says
// nothing about whether the file is referenced. The startup sweep is deliberately
// fail-closed for the same reason; this is the online half of that rule
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> Result<bool, rusqlite::Error> {
let rows = DATABASE.lock_and_select_all(
"SELECT song FROM songs WHERE music_id!=?1 AND song LIKE ?2",
params!(ignored_music_id, format!("%{}%", md5))
)?;
for blob in rows.members() {
let Ok(song) = jzon::parse(&blob.to_string()) else {
// An unparseable row could reference anything - assume it does
return Ok(true);
};
for key in ["play", "select"] {
if song["sound"][key]["md5"].as_str() == Some(md5) {
return Ok(true);
}
}
}
Ok(false)
}
// Resolve a content-addressed chart/jacket md5 to the per-song-id file that
@@ -340,3 +456,10 @@ pub fn find_asset_by_md5(md5: &str) -> Option<(i64, String)> {
}
None
}
// The on-disk database file, so a test can force the read errors the fail-closed
// GC paths are built for
#[cfg(test)]
pub fn test_db_path() -> String {
DATABASE.get_path().to_string()
}
+31 -50
View File
@@ -9,38 +9,36 @@ lazy_static! {
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 CARD: &str = "card";
// Create custom cards/characters, and edit or delete your OWN uploads
pub const CARD_UPLOAD: &str = "card.upload";
// Publish/unpublish and mark obtainable, on your OWN uploads
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 GROUP_MANAGE: &str = "group.manage";
pub const PERMISSION: &str = "permission";
pub const PERMISSION_GRANT: &str = "permission.grant";
pub const PERMISSION_REVOKE: &str = "permission.revoke";
// The whole grantable vocabulary, subtree roots included. Anything not in here
// cannot be written to the table
pub const ANNOUNCEMENT: &str = "announcement";
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] = &[
ALL,
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE
GROUP_MANAGE,
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) {
conn.execute_batch("
CREATE TABLE IF NOT EXISTS grants (
@@ -53,18 +51,11 @@ CREATE TABLE IF NOT EXISTS grants (
").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 {
user_id > 0 && crate::runtime::get_owners().contains(&user_id)
}
// Every scope that would satisfy a request for `scope`: "*", each dotted
// 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> {
fn has_permission(scope: &str) -> Vec<String> {
if scope == ALL {
return vec![String::from(ALL)];
}
@@ -80,7 +71,7 @@ fn implied_by(scope: &str) -> Vec<String> {
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![]);
rows.members().map(|scope| scope.to_string()).collect()
}
@@ -100,13 +91,11 @@ pub fn has(user_id: i64, scope: &str) -> bool {
if is_owner(user_id) {
return true;
}
let held = held_scopes(user_id);
implied_by(scope).iter().any(|candidate| held.contains(candidate))
let held = get_permissions(user_id);
has_permission(scope).iter().any(|candidate| held.contains(candidate))
}
// Everything this user holds, for the webui to hide what it can't use. An
// owner's implicit "*" is reported here even though it has no row
pub fn scopes_for(user_id: i64) -> JsonValue {
pub fn get_user_permissions(user_id: i64) -> JsonValue {
if user_id <= 0 {
return array![];
}
@@ -114,7 +103,7 @@ pub fn scopes_for(user_id: i64) -> JsonValue {
if is_owner(user_id) {
scopes.push(String::from(ALL));
}
for scope in held_scopes(user_id) {
for scope in get_permissions(user_id) {
if !scopes.contains(&scope) {
scopes.push(scope);
}
@@ -148,16 +137,6 @@ pub fn grants() -> JsonValue {
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> {
if user_id <= 0 {
return Err(String::from("Invalid user id"));
@@ -175,9 +154,6 @@ pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> {
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> {
if user_id <= 0 {
return Err(String::from("Invalid user id"));
@@ -198,6 +174,11 @@ pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String>
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)]
mod tests {
use super::*;
@@ -258,7 +239,7 @@ mod tests {
for scope in SCOPES {
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(-1, ALL));
wipe(105);
@@ -276,7 +257,7 @@ mod tests {
insert(110, CARD_UPLOAD, 0);
grant(111, CARD_UPLOAD, 110).unwrap();
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, 110).is_err());
assert!(grant(110, ALL, 110).is_err());
@@ -324,9 +305,9 @@ mod tests {
assert!(has(118, scope), "scope {}", scope);
assert!(has(120, scope), "scope {}", scope);
}
assert_eq!(scopes_for(118).len(), 1);
assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL));
assert!(held_scopes(118).is_empty());
assert_eq!(get_user_permissions(118).len(), 1);
assert_eq!(get_user_permissions(118)[0].to_string(), String::from(ALL));
assert!(get_permissions(118).is_empty());
// An owner can bootstrap-grant, and can't be revoked
grant(119, ALL, 118).unwrap();
assert!(has(119, ALL));
@@ -346,7 +327,7 @@ mod tests {
assert!(!scope.is_empty());
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);
}
}
+8 -5
View File
@@ -1,4 +1,5 @@
mod static_handlers;
mod maintenance;
mod options;
mod router;
mod encryption;
@@ -19,8 +20,9 @@ mod ios;
use actix_web::{rt, App, HttpServer, web, dev::Service};
//use actix_cors::Cors;
use std::time::Duration;
pub use options::get_args;
pub use options::{get_args, Commands};
use runtime::get_data_path;
pub use maintenance::run as run_maintenance;
#[actix_web::main]
pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
@@ -29,17 +31,18 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
runtime::update_owners(&args.owner);
runtime::set_nerf_custom_cards(args.nerf_custom_cards);
if args.purge {
println!("Purging accounts...");
let machines = crate::router::arcade::purge_machines();
println!("Purged {} arcade machines", machines);
let ct = crate::router::userdata::purge_accounts();
println!("Purged {} accounts", ct);
}
router::custom_song::migrate::run();
router::custom_song::sweep_audio();
// 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
// be able to strand every room's seats for the life of the process.
router::custom_3dmv::sweep_blobs();
router::multi_live::start_sweeper();
let rv = HttpServer::new(|| App::new()
+3
View File
@@ -2,6 +2,9 @@
#[cfg(not(feature = "library"))]
fn main() -> std::io::Result<()> {
let args = ew::get_args();
if let Some(ew::Commands::Maintenance { message }) = args.command {
return ew::run_maintenance(args.port, message);
}
ew::runtime::update_data_path(&args.path);
ew::runtime::update_masterdata_path(&args.masterdata);
ew::run_server(false)
+121
View File
@@ -0,0 +1,121 @@
use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};
use jzon::object;
#[actix_web::main]
pub async fn run(port: u16, message: String) -> std::io::Result<()> {
let message = web::Data::new(message);
let server = HttpServer::new(move || {
App::new()
.app_data(message.clone())
.default_service(web::route().to(respond))
})
.bind(("0.0.0.0", port))?
.run();
println!("Maintenance server listening on http://0.0.0.0:{port}");
server.await
}
async fn respond(req: HttpRequest, message: web::Data<String>) -> HttpResponse {
let message = message.get_ref().as_str();
if req.path() == "/maintenance/maintenance.json" {
// The title screen requires HTTP 200, server=false, and an active UTC window.
return HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.content_type("application/json")
.body(object! {
"opened_at": "1970-01-01 00:00:00",
"closed_at": "9999-01-01 00:00:00",
"message": message,
"server": false,
"gamelib": 0
}.dump());
}
let webui = req.path() == "/api/webui" || req.path().starts_with("/api/webui/");
let game_api = req.path() == "/api" || req.path().starts_with("/api/");
let browser = req.headers().get("Accept").and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("text/html"));
if req.headers().contains_key("aoharu-asset-version") || (game_api && !webui && !browser) {
// Do not use global::send: its per-user clock can access the database.
let body = object! {
"code": 10,
"server_time": crate::router::global::timestamp(),
"message": message
}.dump();
return HttpResponse::Ok()
.insert_header(("Cache-Control", "no-store"))
.body(crate::encryption::encrypt_packet(&body).expect("packet encryption failed"));
}
let escaped = message.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
.replace('"', "&quot;").replace('\'', "&#39;");
HttpResponse::ServiceUnavailable()
.insert_header(("Cache-Control", "no-store"))
.content_type("text/html; charset=utf-8")
.body(include_str!("../web_assets/maintenance.html").replace("{{message}}", &escaped))
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::{http::{Method, StatusCode}, test};
const MESSAGE: &str = "Updating \"songs\" & <cards> — またね\nPlease wait.";
#[actix_web::test]
async fn title_check_is_active_and_preserves_message() {
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
.default_service(web::route().to(respond))).await;
let req = test::TestRequest::get().uri("/maintenance/maintenance.json?cache=1").to_request();
let response = test::call_service(&app, req).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers().get("Cache-Control").unwrap(), "no-store");
let body = test::read_body(response).await;
let json = jzon::parse(std::str::from_utf8(&body).unwrap()).unwrap();
assert_eq!(json["message"].as_str(), Some(MESSAGE));
assert_eq!(json["server"].as_bool(), Some(false));
let now = crate::router::global::format_datetime(crate::router::global::timestamp());
assert!(json["opened_at"].as_str().unwrap() < now.as_str());
assert!(json["closed_at"].as_str().unwrap() > now.as_str());
}
#[actix_web::test]
async fn game_requests_get_encrypted_maintenance_without_reading_the_body() {
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
.default_service(web::route().to(respond))).await;
for (path, header) in [("/api/start", false), ("/api/live/end", true),
("/api/unknown", false), ("/v1.0/test", true), ("/anything", true)] {
let mut req = test::TestRequest::post().uri(path)
.insert_header(("aoharu-user-id", "12345")).set_payload("not an encrypted request");
if header { req = req.insert_header(("aoharu-asset-version", "old")); }
let response = test::call_service(&app, req.to_request()).await;
assert_eq!(response.status(), StatusCode::OK, "{path}");
let body = test::read_body(response).await;
let decoded = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
let json = jzon::parse(&decoded).unwrap();
assert_eq!(json["code"].as_i32(), Some(10));
assert_eq!(json["message"].as_str(), Some(MESSAGE));
}
}
#[actix_web::test]
async fn all_other_routes_and_methods_are_maintenance_only() {
let app = test::init_service(App::new().app_data(web::Data::new(MESSAGE.to_string()))
.default_service(web::route().to(respond))).await;
for path in ["/", "/maintenance.html", "/custom_song/upload", "/custom_card/delete",
"/custom_3dmv/upload", "/announcement/create", "/api/webui/login",
"/Android/hash/file", "/v1.0/test", "/api/user", "/unknown"] {
for method in [Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS] {
let response = test::call_service(&app,
test::TestRequest::default().method(method).uri(path)
.insert_header(("Accept", "text/html")).to_request()).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE, "{path}");
let body = test::read_body(response).await;
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains("&lt;cards&gt;"));
assert!(html.contains("またね"));
assert!(!html.contains("<cards>"));
}
}
}
}
+65 -2
View File
@@ -1,9 +1,12 @@
use clap::Parser;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[arg(short, long, default_value_t = 8080, help = "Port to listen on")]
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(short, long, global = true, default_value_t = 8080, help = "Port to listen on")]
pub port: u16,
#[arg(long, default_value = "./", help = "Path to store database files")]
@@ -44,6 +47,21 @@ 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")]
pub enable_custom_cards: bool,
#[arg(long, default_value_t = false, help = "Nerf custom cards: cap base stats below the official per-rarity maxima (median band) and skill values at 80% of the official range. Applies at upload time and to cards already uploaded, which are clamped in every response")]
pub nerf_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")]
pub owner: Vec<i64>,
@@ -71,9 +89,21 @@ pub struct Args {
#[arg(long, default_value = "", help = "Asset version for overridden asset hashes.")]
pub asset_version: String,
#[arg(long, default_value = "", help = "Global asset version for overridden asset hashes.")]
pub en_asset_version: String,
#[arg(long, default_value_t = false, help = "Force an application update for asset versions not marked as latest")]
pub force_updates: bool,
#[arg(long, default_value = "", help = "Asset hash for windows client.")]
pub windows_asset_hash: String,
#[arg(long, default_value = "", help = "Asset hash for linux client.")]
pub linux_asset_hash: String,
#[arg(long, default_value = "", help = "Asset hash for macOS client.")]
pub macos_asset_hash: String,
#[arg(long, default_value = "", help = "Path to image assets.")]
pub image_asset_path: String,
@@ -81,8 +111,41 @@ pub struct Args {
pub masterdata: String
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Start a maintenance-only server until stopped
Maintenance {
/// The message shown to the client
message: String,
},
}
pub fn get_args() -> Args {
#[cfg(not(test))]
let mut args = Args::parse();
#[cfg(test)]
let mut args = Args::parse_from(["ew"]);
crate::runtime::overlay_args(&mut args);
args
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maintenance_command_and_port() {
for argv in [
vec!["ew", "--port", "6017", "maintenance", "Updating songs"],
vec!["ew", "maintenance", "Updating songs", "--port", "6017"],
] {
let args = Args::try_parse_from(argv).unwrap();
assert_eq!(args.port, 6017);
assert!(matches!(args.command, Some(Commands::Maintenance { message }) if message == "Updating songs"));
}
assert!(Args::try_parse_from(["ew", "maintenance"]).is_err());
let normal = Args::try_parse_from(["ew"]).unwrap();
assert!(normal.command.is_none());
assert_eq!(normal.port, 8080);
}
}
+16 -2
View File
@@ -1,4 +1,5 @@
pub mod start;
pub mod arcade;
pub mod global;
pub mod login;
pub mod userdata;
@@ -6,6 +7,7 @@ pub mod user;
pub mod purchase;
pub mod tutorial;
pub mod mission;
pub mod beginner_mission;
pub mod home;
pub mod lottery;
pub mod friend;
@@ -23,6 +25,8 @@ pub mod card;
pub mod shop;
pub mod custom_song;
pub mod custom_card;
pub mod custom_group;
pub mod custom_3dmv;
pub mod rich_text;
pub mod webui;
pub mod clear_rate;
@@ -188,7 +192,7 @@ fn not_found(req: &HttpRequest) -> HttpResponse {
global::send(rv, 0, req)
}
// Fallback for paths no actix route matched. Game endpoints live in each module's routes()
// Fallback
async fn api_req(req: HttpRequest, body: String) -> HttpResponse {
let args = crate::get_args();
if args.hidden && (req.path().starts_with("/api/webui/") || !(req.path().starts_with("/api") || req.path().starts_with("/v1.0"))) {
@@ -222,11 +226,13 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/cheat" => webui::cheat(req, body),
"/api/webui/grantPermission" => webui::grant_permission(req, body),
"/api/webui/revokePermission" => webui::revoke_permission(req, body),
"/api/webui/removeArcadeMachine" => webui::remove_arcade_machine(req, body),
"/api/webui/linkNesicaCard" => webui::link_nesica_card(req, body),
"/api/webui/unlinkNesicaCard" => webui::unlink_nesica_card(req, body),
_ => api_req(req, body).await
}
} else {
match req.path() {
"/web/announcement" => web::announcement(req),
"/api/webui/userInfo" => webui::user(req),
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
"/webui/logout" => webui::logout(req),
@@ -240,7 +246,10 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/listCharacters" => webui::list_characters(req),
"/api/webui/listSkillCenters" => webui::list_skill_centers(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/listArcadeMachines" => webui::list_arcade_machines(req),
"/api/webui/listNesicaCards" => webui::list_nesica_cards(req),
_ => api_req(req, body).await
}
}
@@ -260,10 +269,12 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.route(actix_web::web::get().to(home::gift_get))
.route(actix_web::web::post().to(user::gift))
)
.configure(arcade::routes)
.configure(card::routes)
.configure(chat::routes)
.configure(custom_song::routes)
.configure(custom_card::routes)
.configure(custom_3dmv::routes)
.configure(debug::routes)
.configure(event::routes)
.configure(exchange::routes)
@@ -292,4 +303,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
);
cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::web_routes);
cfg.configure(custom_group::web_routes);
cfg.configure(custom_3dmv::web_routes);
cfg.configure(web::routes);
}
+589
View File
@@ -0,0 +1,589 @@
// Arcade mode: a SIF2 client turned into a rhythm cabinet.
use jzon::{object, JsonValue};
use actix_web::{web, Responder};
use crate::router::{databases, global, live, multi_live, userdata, Api, Body};
use crate::router::userdata::user::migration;
use crate::database::arcade as database;
const DEFAULT_MACHINE_NAME: &str = "ARCADE";
const GUEST_NAME: &str = "GUEST";
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
}
// 0 = forever
fn machine_ttl_days() -> u64 {
crate::get_args().arcade_machine_ttl
}
fn session_ttl() -> i64 {
crate::get_args().arcade_session_ttl as i64 * 60
}
const MAX_SESSION_WINDOWS: i64 = 4;
fn flag(value: &JsonValue) -> bool {
value.as_bool().unwrap_or(false) || value.as_i64().unwrap_or(0) != 0
}
fn card_id(body: &JsonValue) -> Option<String> {
migration::valid_card_id(body["card_id"].as_str().unwrap_or(""))
}
pub fn is_cabinet_account(user_id: i64) -> bool {
!disabled() && database::machine_of_account(user_id).is_some()
}
pub fn card_relinked(card: &str) {
if !disabled() {
database::clear_card_session(card);
}
}
fn live_card_session(user_id: i64, now: i64) -> Option<(String, i64)> {
database::live_card_session(&migration::cards_of_account(user_id), now)
}
fn open_card_session(card: &str, machine_id: &str) {
database::open_card_session(card, machine_id, global::timestamp() as i64 + session_ttl());
}
async fn info() -> impl Responder {
if disabled() {
return Api(None);
}
Api(Some(object!{
"enabled": true,
"machine_ttl_days": machine_ttl_days()
}))
}
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 {
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
}))
}
fn resolve_card(card: &str) -> Option<(i64, String)> {
let user_id = migration::card_user(card)?;
let uuid = userdata::get_login_token(user_id);
if uuid.is_empty() {
println!("arcade: card mapping pointed at missing account {} - unlinking the card", user_id);
migration::remove_card(card);
return None;
}
Some((user_id, uuid))
}
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();
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 Some(card) = card else {
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,
"unlinked": false
}));
};
// A known card plays its own account, with every clear it has ever earned
let Some((user_id, uuid)) = resolve_card(&card) else {
println!("arcade: machine {} presented a card that is linked to no account", machine_id);
return Api(Some(object!{ "unlinked": true }));
};
open_card_session(&card, &machine_id);
let name = userdata::get_name_and_rank(user_id)["user_name"].as_str().unwrap_or("").to_string();
Api(Some(object!{
"user_id": user_id,
"uuid": uuid,
"name": name,
"unlinked": false
}))
}
pub fn bind_card_to(card: &str, user_id: i64) -> Result<i64, String> {
if disabled() {
return Err(String::from("Arcade mode is disabled on this server"));
}
migration::link_card(card, user_id)?;
println!("arcade: card {} now plays as account {}", card, user_id);
Ok(user_id)
}
pub fn bind_card(card: &str, migration_code: &str, pass: &str) -> Result<i64, String> {
if disabled() {
return Err(String::from("Arcade mode is disabled on this server"));
}
let account = userdata::user::migration::get_acc_transfer(migration_code, pass);
if !account["success"].as_bool().unwrap_or(false) || account["user_id"] == 0 {
return Err(String::from("Transfer code and password don't match"));
}
let Some(user_id) = account["user_id"].as_i64() else {
return Err(String::from("Transfer code and password don't match"));
};
bind_card_to(card, user_id)
}
async fn bind(Body(body): Body) -> impl Responder {
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)
}
}
}
// lives
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);
}
live_card_session(user_id, now).map(|_| user_id)
}
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)
}
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)
}
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_cards(&migration::cards_of_account(user_id))
}
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)) = live_card_session(user_id, now) {
let ttl = session_ttl();
database::extend_card_session(&card, (now + ttl).min(opened + ttl * MAX_SESSION_WINDOWS));
}
}
pub fn live_end_body(body: &JsonValue) -> JsonValue {
let mut rv = body.clone();
rv["use_lp"] = multi_live::boost_lp(1).into();
rv
}
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
}
pub fn arcade_retire_user(login_token: &str, body: &JsonValue) -> Option<i64> {
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)
}
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
pub fn purge_machines() -> usize {
if disabled() {
return 0;
}
let ttl = machine_ttl_days();
// 0 = never
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 && !migration::account_has_card(user_id) {
userdata::delete_account(user_id);
}
}
database::delete_machine(machine_id);
}
dead.len()
}
// webui
pub fn webui_machines() -> JsonValue {
if disabled() {
return jzon::array![];
}
database::list_machines()
}
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 && !migration::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(())
}
// Rest of file is tests
#[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());
}
// 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);
}
// A card resolves to the account it names, and to nothing at all once that
// account is gone - and the dead mapping does not survive the answer
#[test]
fn a_card_resolves_to_its_account_and_a_dead_mapping_is_dropped() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
// Nobody linked it: unlinked, and nothing was created for it
assert!(resolve_card("7020392000000001").is_none());
assert!(migration::card_user("7020392000000001").is_none());
// Linked: the account and its token
let (player, token) = userdata::starter::create("Linked").unwrap();
migration::set_card("7020392000000002", player);
assert_eq!(resolve_card("7020392000000002"), Some((player, token)));
// The account was deleted: unlinked from now on, mapping gone
userdata::delete_account(player);
assert!(resolve_card("7020392000000002").is_none());
assert!(migration::card_user("7020392000000002").is_none(), "a mapping to a deleted account survived");
}
// bind_card_to is the cabinet's entrance to the shared link rule
// (migration::link_card): a cabinet's own identities are refused and a card
// that already names an account is re-pointed, never refused
#[test]
fn bind_card_to_refuses_cabinet_identities_and_repoints_a_linked_card() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::arcade as db;
let (machine_account, _) = userdata::starter::create("Cabinet Bind").unwrap();
let (guest_account, _) = userdata::starter::create(GUEST_NAME).unwrap();
let machine_id = db::generate_machine_id();
db::insert_machine(&machine_id, "Cabinet Bind", machine_account, guest_account);
let (player, _) = userdata::starter::create("Player").unwrap();
// The id is validated, not repaired
assert!(bind_card_to("0123-4567", player).is_err());
assert!(migration::card_user("0123-4567").is_none());
// Neither cabinet identity may sit behind a card
let card = "4242424242424242";
assert!(bind_card_to(card, machine_account).is_err());
assert!(bind_card_to(card, guest_account).is_err());
assert!(migration::card_user(card).is_none());
// A card another player linked is re-pointed, and that player's account
// is untouched - the card was the only thing that changed hands
let (previous, _) = userdata::starter::create("Previous").unwrap();
migration::set_card(card, previous);
assert_eq!(bind_card_to(card, player), Ok(player));
assert_eq!(migration::card_user(card), Some(player));
assert!(!userdata::get_acc_from_uid(previous)["error"].as_bool().unwrap_or(false), "re-pointing a card touched the previous account");
db::delete_machine(&machine_id);
for id in [player, previous, machine_account, guest_account] {
userdata::delete_account(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);
migration::set_card("4444333322221111", claimed_machine);
// A player account with a card, belonging to no cabinet at all
let (player, player_token) = userdata::starter::create("Player").unwrap();
migration::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!(migration::card_user("4444333322221111"), Some(claimed_machine));
// A card-bound player account is never a candidate in the first place
assert_eq!(userdata::uid_from_login_token(&player_token), player);
db::delete_machine(&live_id);
}
}
+315
View File
@@ -0,0 +1,315 @@
use jzon::{array, object, JsonValue};
use lazy_static::lazy_static;
use super::{databases, items};
use databases::csv::{self, Region};
lazy_static! {
static ref CELLS: JsonValue = csv::table(Region::Jp, "beginner_mission");
static ref SETTINGS: JsonValue = csv::table(Region::Jp, "beginner_mission_reward_setting");
static ref REWARDS: JsonValue = csv::table(Region::Jp, "beginner_mission_reward");
static ref CARD_LEVELS: JsonValue = csv::table(Region::Jp, "card_level");
static ref SKILL_LEVELS: JsonValue = csv::table(Region::Jp, "card_skill_level");
}
// JP /api/mission + /api/mission/receive captures, 2024-03-31:
// all sheets accumulate progress; only reached sheets can be claimed. Line
// rewards are milestones in the number of completed lines, not named lines.
const LINES: [[i64; 3]; 8] = [
[1, 2, 3], [4, 5, 6], [7, 8, 9], [1, 4, 7],
[2, 5, 8], [3, 6, 9], [1, 5, 9], [3, 5, 7],
];
pub fn contains(id: i64) -> bool {
CELLS.members().any(|c| c["masterMissionId"].as_i64() == Some(id))
}
pub fn level(id: i64) -> i64 {
CELLS.members().find(|c| c["masterMissionId"].as_i64() == Some(id))
.and_then(|c| c["level"].as_i64()).unwrap_or(0)
}
pub fn is_counter(id: i64) -> bool {
contains(id) && matches!(databases::MISSION_LIST[id.to_string()]["conditionType"].as_i64(), Some(5 | 6 | 53 | 62 | 63))
}
pub fn ensure(missions: &mut JsonValue) {
for cell in CELLS.members() {
let id = cell["masterMissionId"].as_i64().unwrap();
let target = databases::MISSION_LIST[id.to_string()]["conditionNumber"].as_i64().unwrap();
if let Some(mission) = missions.members_mut().find(|m| m["master_mission_id"].as_i64() == Some(id)) {
// The old login hook kept incrementing an already completed cell.
// The client subtracts one to animate an unclaimed completion, so
// over-target progress would incorrectly make its "before" sheet clear.
let progress = if mission["status"].as_i64().unwrap_or(1) >= 2 { target }
else { mission["progress"].as_i64().unwrap_or(0).clamp(0, target) };
mission["progress"] = progress.into();
continue;
}
missions.push(object! {
master_mission_id: id, status: 1, progress: 0,
expire_date_time: 0, not_visible: 0
}).unwrap();
}
}
pub fn set_progress(id: i64, progress: i64, missions: &mut JsonValue) -> bool {
ensure(missions);
let target = databases::MISSION_LIST[id.to_string()]["conditionNumber"].as_i64().unwrap();
for mission in missions.members_mut() {
if mission["master_mission_id"].as_i64() != Some(id) { continue; }
if mission["status"].as_i64().unwrap_or(1) >= 2 { return false; }
let progress = progress.max(mission["progress"].as_i64().unwrap_or(0)).min(target);
mission["progress"] = progress.into();
mission["status"] = if progress >= target { 2 } else { 1 }.into();
return progress >= target;
}
false
}
pub fn advance(condition: i64, value: Option<i64>, amount: i64, missions: &mut JsonValue) -> JsonValue {
ensure(missions);
let mut cleared = array![];
for cell in CELLS.members() {
let id = cell["masterMissionId"].as_i64().unwrap();
let mst = &databases::MISSION_LIST[id.to_string()];
if mst["conditionType"].as_i64() != Some(condition) { continue; }
if let Some(value) = value {
if mst["conditionValues"][0].as_i64() != Some(value) { continue; }
}
let old = items::get_mission_status(id, missions)["progress"].as_i64().unwrap_or(0);
if set_progress(id, old + amount, missions) { cleared.push(id).unwrap(); }
}
cleared
}
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) -> JsonValue {
ensure(missions);
let mut cleared = array![];
for cell in CELLS.members() {
let id = cell["masterMissionId"].as_i64().unwrap();
let mst = &databases::MISSION_LIST[id.to_string()];
let value = mst["conditionValues"][0].as_i64().unwrap_or(0);
let progress = match mst["conditionType"].as_i64().unwrap() {
14 | 64 => {
let skill = mst["conditionType"] == 64;
user["card_list"].members().filter(|card| {
let master = super::custom_card::card_info(card["master_card_id"].as_i64().unwrap_or(0));
let curve = if skill { databases::CARD_RARITY[master["rarity"].to_string()]["masterCardSkillLevelId"].as_i64() }
else { master["masterCardLevelId"].as_i64() };
let levels = if skill { &*SKILL_LEVELS } else { &*CARD_LEVELS };
levels.members().any(|l| l["id"].as_i64() == curve && l["level"].as_i64() == Some(value)
&& card[if skill { "skill_exp" } else { "exp" }].as_i64().unwrap_or(0) >= l["exp"].as_i64().unwrap())
}).count() as i64
}
17 => user["card_list"].members().filter(|c| !c["evolve"].is_empty()).count() as i64,
58 => user["character_list"].members().filter_map(|c| c["exp"].as_i64()).max().unwrap_or(0),
30 | 60 => {
let mut best = 0;
for deck in user["deck_list"].members() {
let mut types = [0i64; 3];
let mut points = [0i64; 3];
for slot in deck["main_card_ids"].members() {
let Some(card) = user["card_list"].members().find(|c| c["id"] == *slot) else { continue; };
let master = super::custom_card::card_info(card["master_card_id"].as_i64().unwrap_or(0));
if let Some(t) = master["type"].as_usize().filter(|t| (1..=3).contains(t)) { types[t-1] += 1; }
let level = CARD_LEVELS.members().filter(|l| l["id"] == master["masterCardLevelId"]
&& l["exp"].as_i64().unwrap() <= card["exp"].as_i64().unwrap_or(0))
.max_by_key(|l| l["level"].as_i64());
if let Some(l) = level {
for (i, name) in ["smile", "pure", "cool"].iter().enumerate() {
points[i] += master[*name].as_i64().unwrap_or(0) * l[format!("{}Ratio",name)].as_i64().unwrap_or(0) / 10000;
}
}
}
let p = if mst["conditionType"] == 30 {
let needed = mst["conditionValues"][1].as_i64().unwrap();
(types.iter().any(|n| *n >= needed)) as i64
} else { *points.iter().max().unwrap() };
best = best.max(p);
}
best
}
_ => continue,
};
if set_progress(id, progress, missions) { cleared.push(id).unwrap(); }
}
cleared
}
pub fn refresh_account(user: &JsonValue, missions: &mut JsonValue) -> JsonValue {
let mut cleared = refresh(user, missions);
// Existing accounts may have prepared transfer credentials before missions
// were enabled. Merely requesting a code leaves an empty password.
if let Some(uid) = user["user"]["id"].as_i64() {
if super::userdata::has_transfer_password(uid) {
for id in advance(25, None, 1, missions).members() { cleared.push(id.clone()).unwrap(); }
}
}
cleared
}
fn sheet_cells(group: i64, level: i64) -> impl Iterator<Item = &'static JsonValue> {
CELLS.members().filter(move |c| c["id"].as_i64() == Some(group) && c["level"].as_i64() == Some(level))
}
fn claimed(group: i64, level: i64, missions: &JsonValue) -> Vec<i64> {
sheet_cells(group, level).filter(|c| {
items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3
}).filter_map(|c| c["number"].as_i64()).collect()
}
pub fn claimable(id: i64, missions: &JsonValue) -> bool {
let Some(cell) = CELLS.members().find(|c| c["masterMissionId"].as_i64() == Some(id)) else { return false; };
CELLS.members().filter(|c| c["id"] == cell["id"] && c["level"].as_i64() < cell["level"].as_i64())
.all(|c| items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3)
}
pub fn home_status(missions: &JsonValue) -> (usize, bool) {
let all = CELLS.members().all(|c| items::get_mission_status(c["masterMissionId"].as_i64().unwrap(), missions)["status"] == 3);
let count = CELLS.members().filter(|c| {
let id = c["masterMissionId"].as_i64().unwrap();
items::get_mission_status(id, missions)["status"] == 2 && claimable(id, missions)
}).count();
(count, all)
}
pub fn new_rewards(before: &JsonValue, after: &JsonValue) -> Vec<JsonValue> {
let mut settings: Vec<_> = SETTINGS.members().collect();
// Captures return line milestones before the full-sheet reward.
settings.sort_by_key(|s| (s["masterBeginnerMissionId"].as_i64(), s["level"].as_i64(),
if s["number"] == 0 { 9 } else { s["number"].as_i64().unwrap() }));
let mut result = Vec::new();
for setting in settings {
let group = setting["masterBeginnerMissionId"].as_i64().unwrap();
let level = setting["level"].as_i64().unwrap();
let number = setting["number"].as_i64().unwrap();
// JP cross-sheet claims award bonuses only for sheets unlocked before
// the request (recorded claims 8 and 11 in the replay fixture).
let first = sheet_cells(group, level).next().unwrap()["masterMissionId"].as_i64().unwrap();
if !claimable(first, before) { continue; }
let achieved = |missions: &JsonValue| {
let cells = claimed(group, level, missions);
if number == 0 { cells.len() == 9 }
else { LINES.iter().filter(|line| line.iter().all(|n| cells.contains(n))).count() >= number as usize }
};
if !achieved(before) && achieved(after) {
result.extend(REWARDS.members().filter(|r| r["id"] == setting["masterBeginnerMissionRewardId"]).cloned());
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_inventory_drives_training_bond_and_deck_goals() {
let mut user = object! { card_list: [], character_list: [{exp: 30}], deck_list: [] };
let master = super::super::custom_card::card_info(10010001);
let exp = CARD_LEVELS.members().find(|l| l["id"] == master["masterCardLevelId"] && l["level"] == 30).unwrap()["exp"].clone();
let curve = &databases::CARD_RARITY[master["rarity"].to_string()]["masterCardSkillLevelId"];
let skill_exp = SKILL_LEVELS.members().find(|l| l["id"] == *curve && l["level"] == 2).unwrap()["exp"].clone();
for id in 1..=5 {
user["card_list"].push(object! {id: id, master_card_id: 10010001,
exp: exp.clone(), skill_exp: skill_exp.clone(), evolve: [{type: 2, count: 1}]}).unwrap();
}
user["deck_list"].push(object! {main_card_ids: [1,2,3,4,5,0,0,0,0]}).unwrap();
let mut missions = array![];
let cleared = refresh(&user, &mut missions);
for id in [1614001,1614002,1614003,1664001,1664002,1617001,1658001,1658002,1658003,1630001] {
assert!(cleared.contains(id), "mission {}", id);
}
assert!(refresh(&user, &mut missions).is_empty());
assert_eq!(items::get_mission_status(1605001, &missions)["progress"], 0);
}
#[test]
fn counters_match_values_and_home_counts_only_unlocked_sheet() {
let mut missions = array![];
assert!(advance(63, Some(1110001), 20, &mut missions).is_empty());
assert_eq!(advance(63, Some(8110001), 10, &mut missions), array![1663001]);
assert_eq!(items::get_mission_status(1663002, &missions)["progress"], 10);
assert_eq!(advance(63, Some(8110001), 10, &mut missions), array![1663002]);
assert!(advance(6, Some(1), 1, &mut missions).is_empty());
assert_eq!(advance(6, Some(2), 1, &mut missions), array![1606001]);
advance(53, None, 3, &mut missions);
assert_eq!(home_status(&missions), (2, false));
for row in missions.members_mut() { row["status"] = 3.into(); }
assert_eq!(home_status(&missions), (0, true));
assert!(advance(53, None, 1, &mut missions).is_empty());
}
#[test]
fn all_recorded_jp_beginner_claims_match() {
let cases = jzon::parse(include_str!("beginner_mission_claims.json")).unwrap();
assert_eq!(cases.len(), 15);
for (i, case) in cases.members().enumerate() {
let mut before = array![];
ensure(&mut before);
for row in before.members_mut() {
if case["before"].contains(row["master_mission_id"].as_i64().unwrap()) { row["status"] = 3.into(); }
}
let mut after = before.clone();
for row in after.members_mut() {
if case["claim"].contains(row["master_mission_id"].as_i64().unwrap()) { row["status"] = 3.into(); }
}
let expected: Vec<_> = case["bonus"].members().map(|x| x.as_i64().unwrap()).collect();
let actual: Vec<_> = new_rewards(&before, &after).iter().map(|x| x["amount"].as_i64().unwrap()).collect();
assert_eq!(actual, expected, "JP claim {}", i);
}
}
#[test]
fn progress_is_capped_and_claims_are_not_reset() {
let mut m = array![];
advance(5, None, 12, &mut m);
assert_eq!(items::get_mission_status(1605001, &m)["progress"], 1);
assert_eq!(items::get_mission_status(1605002, &m)["progress"], 10);
assert_eq!(items::get_mission_status(1605003, &m)["progress"], 12);
assert!(!claimable(1605002, &m));
for row in m.members_mut() { if row["master_mission_id"] == 1605001 { row["status"] = 3.into(); } }
advance(5, None, 1, &mut m);
assert_eq!(items::get_mission_status(1605001, &m)["status"], 3);
for row in m.members_mut() {
if row["master_mission_id"] == 1653001 {
row["status"] = 2.into();
row["progress"] = 50.into();
}
}
ensure(&mut m);
assert_eq!(items::get_mission_status(1653001, &m)["progress"], 1);
}
#[test]
fn captured_first_sheet_claim_awards_one_line() {
let mut before = array![];
ensure(&mut before);
let mut after = before.clone();
for row in after.members_mut() {
if [1653001,1605001,1663001,1666001,1658001].contains(&row["master_mission_id"].as_i64().unwrap()) {
row["status"] = 3.into();
}
}
let r = new_rewards(&before, &after);
assert_eq!(r.len(), 1);
assert_eq!(r[0]["amount"], 50);
assert!(new_rewards(&after, &after).is_empty());
}
#[test]
fn full_sheet_gives_eight_lines_and_sheet_once() {
let mut before = array![];
ensure(&mut before);
let mut after = before.clone();
for row in after.members_mut() {
if sheet_cells(1,1).any(|c| c["masterMissionId"] == row["master_mission_id"]) { row["status"] = 3.into(); }
}
let r = new_rewards(&before, &after);
assert_eq!(r.len(), 9);
assert_eq!(r.iter().map(|r| r["amount"].as_i64().unwrap()).sum::<i64>(), 1000);
assert!(claimable(1605002, &after));
assert!(!home_status(&after).1);
}
}
+314
View File
@@ -0,0 +1,314 @@
[
{
"before": [],
"claim": [
1653001
],
"bonus": []
},
{
"before": [
1653001,
1660001,
1666001
],
"claim": [
1605001,
1663001,
1614001,
1658001,
1661001
],
"bonus": [
50,
50,
50,
50,
50
]
},
{
"before": [],
"claim": [
1653001
],
"bonus": []
},
{
"before": [],
"claim": [
1653001
],
"bonus": []
},
{
"before": [],
"claim": [
1653001
],
"bonus": []
},
{
"before": [
1653001
],
"claim": [
1605001,
1663001,
1660001,
1614001
],
"bonus": [
50
]
},
{
"before": [],
"claim": [
1653001,
1605001,
1663001,
1666001,
1658001
],
"bonus": [
50
]
},
{
"before": [
1653001,
1605001,
1663001,
1666001,
1658001
],
"claim": [
1614001
],
"bonus": []
},
{
"before": [
1653001,
1605001,
1663001,
1614001,
1666001,
1658001
],
"claim": [
1662001,
1660001,
1661001,
1653002,
1658002,
1630001,
1663002
],
"bonus": [
50,
50,
50,
50,
50,
100,
100,
500
]
},
{
"before": [
1653001,
1605001,
1662001,
1663001,
1660001,
1614001,
1666001,
1658001,
1661001,
1653002,
1658002,
1630001,
1663002
],
"claim": [
1665001
],
"bonus": [
50
]
},
{
"before": [
1653001,
1605001,
1662001,
1663001,
1660001,
1614001,
1666001,
1658001,
1661001,
1653002,
1665001,
1658002,
1630001,
1663002
],
"claim": [
1664001,
1614002
],
"bonus": [
50
]
},
{
"before": [
1653001,
1605001,
1662001,
1663001,
1660001,
1614001,
1666001,
1658001,
1661001,
1653002,
1665001,
1664001,
1658002,
1630001,
1614002,
1663002
],
"claim": [
1605002,
1606001,
1653003,
1660002,
1617001,
1658003,
1625001
],
"bonus": [
50,
50,
50,
100,
100,
500
]
},
{
"before": [
1653001,
1605001,
1662001,
1663001,
1660001,
1614001,
1666001,
1658001,
1661001,
1653002,
1605002,
1665001,
1664001,
1658002,
1630001,
1606001,
1614002,
1663002,
1653003,
1660002,
1617001,
1658003,
1625001
],
"claim": [
1626001
],
"bonus": [
50,
50
]
},
{
"before": [
1653001,
1605001,
1662001,
1663001,
1660001,
1614001,
1666001,
1658001,
1661001,
1653002,
1605002,
1665001,
1664001,
1658002,
1630001,
1606001,
1614002,
1663002,
1653003,
1660002,
1617001,
1658003,
1625001,
1626001
],
"claim": [
1614003,
1664002
],
"bonus": [
50,
50,
50
]
},
{
"before": [
1653001,
1605001,
1662001,
1663001,
1660001,
1614001,
1666001,
1658001,
1661001,
1653002,
1605002,
1665001,
1664001,
1658002,
1630001,
1606001,
1614002,
1663002,
1653003,
1660002,
1617001,
1658003,
1625001,
1614003,
1664002,
1626001
],
"claim": [
1605003
],
"bonus": [
100,
100,
500
]
}
]
+11
View File
@@ -134,6 +134,11 @@ async fn reinforce(Session { key, body }: Session) -> impl Responder {
let mut clear_mission_ids = array![];
let card = do_reinforce(&mut user, &body, "exp", 1, false, &mut array![], &mut array![], &mut clear_mission_ids);
let mut missions = userdata::get_acc_missions(&key);
for id in super::beginner_mission::refresh(&user, &mut missions).members() {
clear_mission_ids.push(id.clone()).unwrap();
}
userdata::save_acc_missions(&key, missions);
userdata::save_acc(&key, user.clone());
@@ -150,6 +155,11 @@ async fn skill_reinforce(Session { key, body }: Session) -> impl Responder {
let mut clear_mission_ids = array![];
let card = do_reinforce(&mut user, &body, "skill_exp", 10, false, &mut array![], &mut array![], &mut clear_mission_ids);
let mut missions = userdata::get_acc_missions(&key);
for id in super::beginner_mission::refresh(&user, &mut missions).members() {
clear_mission_ids.push(id.clone()).unwrap();
}
userdata::save_acc_missions(&key, missions);
userdata::save_acc(&key, user.clone());
@@ -168,6 +178,7 @@ async fn evolve(Session { key, body }: Session) -> impl Responder {
let mut clear_mission_ids = array![];
let card = do_reinforce(&mut user, &body, "", 0, true, &mut missions, &mut chats, &mut clear_mission_ids);
for id in super::beginner_mission::refresh(&user, &mut missions).members() { clear_mission_ids.push(id.clone()).unwrap(); }
userdata::save_acc(&key, user.clone());
userdata::save_acc_chats(&key, chats);
+68
View File
@@ -60,6 +60,7 @@ pub fn add_chat_from_chapter_id(chapter_id: i64, chats: &mut JsonValue) -> bool
}
async fn home(Login(key): Login) -> impl Responder {
refresh_birthday(&key, &userdata::get_acc(&key));
let chats = userdata::get_acc_chats(&key);
let mut rooms = array![];
@@ -75,6 +76,54 @@ async fn home(Login(key): Login) -> impl Responder {
}))
}
pub fn refresh_birthday(key: &str, user: &JsonValue) {
if !super::user::valid_birthday(user["user"]["birthday"].as_str().unwrap_or("")) { return; }
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
if !is_birthday(user, now) { return; }
let mut chats = userdata::get_acc_chats(key);
let mut missions = userdata::get_acc_missions(key);
if grant_birthday_chats(user, &mut chats, &mut missions, now) {
userdata::save_acc_chats(key, chats);
userdata::save_acc_missions(key, missions);
}
}
fn is_birthday(user: &JsonValue, now: u64) -> bool {
let date = global::format_datetime(now);
let birthday = user["user"]["birthday"].as_str().unwrap_or("");
birthday == format!("{}{}", &date[5..7], &date[8..10])
}
fn grant_birthday_chats(user: &JsonValue, chats: &mut JsonValue, missions: &mut JsonValue, now: u64) -> bool {
if !is_birthday(user, now) { return false; }
let mut changed = false;
for (_, master) in databases::MISSION_LIST.entries() {
if master["conditionType"] != 75 { continue; }
let character = &master["conditionValues"][0];
let target = master["conditionNumber"].as_i64().unwrap();
if !user["character_list"].members().any(|c|
c["master_character_id"] == *character && c["exp"].as_i64().unwrap_or(0) >= target) { continue; }
let id = master["id"].as_i64().unwrap();
if items::get_mission_status(id, missions)["status"] == 3 { continue; }
for reward in databases::MISSION_REWARDS[master["masterMissionRewardId"].to_string()].members().filter(|r| r["type"] == 16) {
for (_, by_room) in databases::CHATS.entries() {
for (_, room) in by_room.entries().filter(|(_, r)| r["id"] == reward["value"]) {
add_chat(room["masterChatId"].as_i64().unwrap(), room["roomId"].as_i64().unwrap(), chats);
}
}
}
if !missions.members().any(|m| m["master_mission_id"] == id) {
missions.push(object! {master_mission_id: id, status: 1, progress: 0, expire_date_time: 0, not_visible: 0}).unwrap();
}
for mission in missions.members_mut().filter(|m| m["master_mission_id"] == id) {
mission["status"] = 3.into();
mission["progress"] = target.into();
}
changed = true;
}
changed
}
async fn start() -> impl Responder {
Api(Some(object!{"select_talk_id_list":[],"get_item_list":[],"is_read":0}))
}
@@ -102,6 +151,25 @@ async fn end(Session { key, body }: Session) -> impl Responder {
mod tests {
use super::*;
#[test]
fn birthday_chats_require_the_date_and_bond_and_are_not_duplicated() {
let _test = crate::runtime::lock_test_data_path();
let user = object! { user: {birthday: "0531"}, character_list: [
{master_character_id: 1001, exp: 50}, {master_character_id: 1002, exp: 49}
]};
let mut chats = array![];
let mut missions = array![];
let day = global::parse_datetime("2024/05/31 12:00:00").unwrap();
assert!(!grant_birthday_chats(&user, &mut chats, &mut missions, day - 86400));
assert!(grant_birthday_chats(&user, &mut chats, &mut missions, day));
assert_eq!(chats.len(), 1);
assert_eq!(chats[0]["chapter_id"], 100100301);
assert_eq!(missions[0]["status"], 3);
assert_eq!(missions[0]["progress"], 50);
assert!(!grant_birthday_chats(&user, &mut chats, &mut missions, day));
assert_eq!(chats.len(), 1);
}
// Verbatim from an official /api/chat/home capture (JP log, the 65-occurrence
// baseline seen on accounts that had earned no extra stamps yet). Both the JP and EN
// chat_stamp tables reproduce it exactly from _initialStamp, which is what lets
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
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())
}
// Reads one entry, capped. Deflate's ceiling is about 1032:1, so an uncapped
// read_to_end here is a zip bomb: a one-megabyte entry inflates to a gigabyte and
// grows the Vec until the allocator or the OOM killer stops it, and a package has
// 39 addressable entries. Every entry is bounded by the upload form's own
// per-file cap, and by what is left of the per-request budget across all entries.
//
// The central directory's declared size rejects the obvious case without
// inflating anything; take(cap + 1) makes that declaration untrusted - a lying
// header runs out of budget one byte past the cap and stops there.
//
// Ok(None) is "the package does not carry this entry", a normal outcome for every
// optional role
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str, remaining: &mut usize) -> Result<Option<Vec<u8>>, String> {
let Ok(mut file) = archive.by_name(name) else {
return Ok(None);
};
let cap = std::cmp::min(super::MAX_FILE_BYTES, *remaining);
// Which limit the entry actually ran into, so the message names the right one
let too_big = if cap >= super::MAX_FILE_BYTES {
super::over_file_limit(name)
} else {
super::over_request_limit()
};
if file.size() > cap as u64 {
return Err(too_big);
}
let mut bytes = Vec::new();
file.by_ref().take(cap as u64 + 1).read_to_end(&mut bytes)
.map_err(|_| format!("Package entry '{}' could not be read", name))?;
if bytes.len() > cap {
return Err(too_big);
}
*remaining -= bytes.len();
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"))?;
// The decompressed budget for the whole package, shared by every entry
let mut remaining = super::MAX_REQUEST_BYTES;
let manifest = read_entry(&mut archive, "manifest.json", &mut remaining)?.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), &mut remaining)? {
fields.insert(format!("{}_{}", role, slot), bytes);
}
}
}
for name in ["camera", "config", "stage"] {
if let Some(bytes) = read_entry(&mut archive, name, &mut remaining)? {
fields.insert(String::from(name), bytes);
}
}
Ok(())
}
+121
View File
@@ -0,0 +1,121 @@
// Structural validation of a VMD (Vocaloid Motion Data) upload: the header
// magic plus a full section walk with bounds checks, so a truncated or
// corrupt file is rejected cheaply at upload time instead of crashing a
// client mid-live. Nothing here decodes the animation - the stored bytes are
// served verbatim and the client owns the semantics.
//
// Layout (little-endian): 30-byte magic "Vocaloid Motion Data 0002"
// (NUL-padded), 20-byte model name, then up to 6 sections, each a uint32
// count followed by fixed-size records - bone (111), morph (23), camera (61),
// light (28), self-shadow (9) - and the property/IK section whose records are
// variable-sized (9 bytes + 21 per IK entry). Camera-only and motion-only
// files legitimately end early: EOF on a section boundary reads as count 0.
const MAGIC_V2: &[u8] = b"Vocaloid Motion Data 0002";
const MAGIC_V1: &[u8] = b"Vocaloid Motion Data file";
const HEADER_LEN: usize = 30 + 20;
// (record size, section name) for the fixed-size sections, in file order
const FIXED_SECTIONS: &[(usize, &str)] = &[
(111, "bone"),
(23, "morph"),
(61, "camera"),
(28, "light"),
(9, "self-shadow")
];
fn read_count(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
Some(u32::from_le_bytes(bytes.get(offset..end)?.try_into().unwrap()))
}
pub fn validate(label: &str, bytes: &[u8]) -> Result<(), String> {
if bytes.len() < HEADER_LEN {
return Err(format!("'{}' is too short to be a VMD file", label));
}
if bytes.starts_with(MAGIC_V1) {
return Err(format!("'{}' is a version 1 VMD (\"Vocaloid Motion Data file\") - re-save it as version 2 in MMD", label));
}
if !bytes.starts_with(MAGIC_V2) {
return Err(format!("'{}' is not a VMD file (missing the \"Vocaloid Motion Data 0002\" header)", label));
}
let mut offset = HEADER_LEN;
for (record_size, section) in FIXED_SECTIONS {
// EOF exactly on a section boundary: the remaining sections are absent
if offset == bytes.len() {
return Ok(());
}
let Some(count) = read_count(bytes, offset) else {
return Err(format!("'{}' is truncated in the {} section header", label, section));
};
offset += 4;
let section_len = (count as usize).checked_mul(*record_size)
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
.ok_or(format!("'{}' is truncated: the {} section claims {} records past the end of the file", label, section, count))?;
offset += section_len;
}
// Property/IK section: uint32 frame, byte visible, uint32 ikCount, then
// ikCount x (20-byte bone name + 1-byte enabled)
if offset == bytes.len() {
return Ok(());
}
let Some(count) = read_count(bytes, offset) else {
return Err(format!("'{}' is truncated in the property section header", label));
};
offset += 4;
for _ in 0..count {
let Some(ik_count) = read_count(bytes, offset + 5) else {
return Err(format!("'{}' is truncated in the property section", label));
};
let record_len = (ik_count as usize).checked_mul(21)
.and_then(|len| len.checked_add(9))
.filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len()))
.ok_or(format!("'{}' is truncated in the property section", label))?;
offset += record_len;
}
// Trailing bytes after the last section are tolerated, like MMD does
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn structure_walk_accepts_real_shapes_and_rejects_corruption() {
let vmd = crate::router::custom_3dmv::tests::test_vmd(1);
assert!(validate("motion_1", &vmd).is_ok());
// Camera-only file: sections end after camera
let cam = crate::router::custom_3dmv::tests::test_camera_vmd(2);
assert!(validate("camera", &cam).is_ok());
// A bare header with every section absent is structurally fine
let mut bare = Vec::new();
bare.extend(MAGIC_V2);
bare.resize(HEADER_LEN, 0);
assert!(validate("motion_1", &bare).is_ok());
// Truncations and lies
assert!(validate("motion_1", &vmd[..vmd.len() - 1]).unwrap_err().contains("truncated"));
assert!(validate("motion_1", &vmd[..HEADER_LEN + 2]).unwrap_err().contains("truncated"));
let mut liar = vmd.clone();
liar[HEADER_LEN] = 200; // bone count far past EOF
assert!(validate("motion_1", &liar).unwrap_err().contains("claims 200 records"));
// A count that would overflow the length math
let mut overflow = bare.clone();
overflow.extend(u32::MAX.to_le_bytes());
assert!(validate("motion_1", &overflow).unwrap_err().contains("truncated"));
// Wrong or old magic
assert!(validate("motion_1", b"garbage").unwrap_err().contains("too short"));
let mut wrong = vmd.clone();
wrong[0] = b'X';
assert!(validate("motion_1", &wrong).unwrap_err().contains("not a VMD"));
let mut v1 = vmd.clone();
v1[..MAGIC_V1.len()].copy_from_slice(MAGIC_V1);
assert!(validate("motion_1", &v1).unwrap_err().contains("version 1"));
}
}
+686 -43
View File
@@ -13,6 +13,7 @@ use crate::router::{databases, global, rich_text, userdata, webui, Login, Api};
use crate::router::databases::csv::{table, Region};
use crate::router::custom_song::audio;
use crate::database::custom_card as database;
use crate::database::custom_group;
use crate::database::permissions;
use crate::runtime::get_data_path;
use crate::lock_onto_mutex;
@@ -52,6 +53,13 @@ pub const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_REQUEST_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_CARDS_PER_USER: i64 = 500;
// Per-account storage quota over the stored art and voiceline bytes, shared by
// cards and characters (they share one storage root). One card derives 14 PNGs up
// to 2048x1260, so the 500-card count limit alone allowed several gigabytes per
// account with no byte bound at all; 2GiB is around a hundred full cards, well
// past any real uploader, and matches the custom-song quota
pub const MAX_BYTES_PER_USER: i64 = 2 * 1024 * 1024 * 1024;
// Columns the uploader never supplies. master_release_label_id must be 1: a
// closed label filters the card out of the member-list filters and drops its
// evolve conditions
@@ -127,6 +135,24 @@ const CHARACTER_ART: &[ArtKind] = &[
ArtKind { kind: "character", width: 600, height: 920 }
];
// One uploadable voiceline moment. `name` is the wire kind: it is the infix of
// the multipart field names below, the "kind" of every stored/served line, and
// what the client maps to a SYSTEM_VOICE_TYPE (and, for the day_* moments, to
// the date_condition id the game's resolver compares). `category` only groups
// the upload form.
//
// The home-screen moments (category "home") are exactly the SYSTEM_VOICE_TYPEs
// HomeScene.CreateVoiceData asks the resolver for; everything else is a moment
// that already shipped. Kind names are the snake_case of the game's own
// SYSTEM_VOICE_TYPE members (day_* follow the game's own SYS_VOICE_DAY_MMDD cue
// naming), so the mapping stays readable on both sides.
pub struct VoiceKind {
pub name: &'static str,
pub label: &'static str,
pub label_en: &'static str,
pub category: &'static str,
}
// Optional voicelines per character. Multipart fields per line:
// voice_{kind}_{index} audio file (any format symphonia reads;
// transcoded to ogg-vorbis, stored
@@ -137,9 +163,71 @@ const CHARACTER_ART: &[ArtKind] = &[
// Absent slots keep their stored line, captions without a file update the
// stored line's captions, and surviving lines are renumbered contiguously
// per kind (1..n) after every edit
const VOICE_KINDS: &[&str] = &[
"live_start", "live_success", "live_failed", "result_bond",
"skill_smile", "skill_pure", "skill_cool"
pub const VOICE_KINDS: &[VoiceKind] = &[
// --- moments outside the home screen (unchanged wire names) ---
VoiceKind { name: "live_start", label: "ライブ開始(リーダー)", label_en: "Live start (leader, before a live)", category: "live" },
VoiceKind { name: "live_success", label: "ライブクリア", label_en: "Live cleared", category: "live" },
VoiceKind { name: "live_failed", label: "ライブ失敗", label_en: "Live failed", category: "live" },
VoiceKind { name: "result_bond", label: "リザルト・絆獲得", label_en: "Bond gained (live result)", category: "result" },
VoiceKind { name: "skill_smile", label: "スキル発動(スマイル)", label_en: "Skill activation - Smile cards", category: "skill" },
VoiceKind { name: "skill_pure", label: "スキル発動(ピュア)", label_en: "Skill activation - Pure cards", category: "skill" },
VoiceKind { name: "skill_cool", label: "スキル発動(クール)", label_en: "Skill activation - Cool cards", category: "skill" },
// --- home screen: always in the pool ---
VoiceKind { name: "random", label: "ランダム", label_en: "Random line (always in the pool)", category: "home" },
VoiceKind { name: "random_evolution", label: "ランダム(覚醒後)", label_en: "Random line, awakened card only", category: "home" },
VoiceKind { name: "random_unevolution_smile", label: "ランダム(未覚醒・スマイル)", label_en: "Random line, un-awakened Smile card", category: "home" },
VoiceKind { name: "random_unevolution_pure", label: "ランダム(未覚醒・ピュア)", label_en: "Random line, un-awakened Pure card", category: "home" },
VoiceKind { name: "random_unevolution_cool", label: "ランダム(未覚醒・クール)", label_en: "Random line, un-awakened Cool card", category: "home" },
VoiceKind { name: "random_evolution_smile", label: "ランダム(覚醒後・スマイル)", label_en: "Random line, awakened Smile card", category: "home" },
VoiceKind { name: "random_evolution_pure", label: "ランダム(覚醒後・ピュア)", label_en: "Random line, awakened Pure card", category: "home" },
VoiceKind { name: "random_evolution_cool", label: "ランダム(覚醒後・クール)", label_en: "Random line, awakened Cool card", category: "home" },
VoiceKind { name: "touch", label: "タップ", label_en: "Tapped (the touch-to-play-voice button)", category: "home" },
// --- home screen: time of day (the game's own hour bands) ---
VoiceKind { name: "time", label: "時間帯(共通)", label_en: "Any time of day", category: "home" },
VoiceKind { name: "time_firsthalf", label: "時間帯(5時〜17時)", label_en: "First half of the day (05-17)", category: "home" },
VoiceKind { name: "time_latterhalf", label: "時間帯(17時〜5時)", label_en: "Latter half of the day (17-05)", category: "home" },
VoiceKind { name: "time_morning", label: "時間帯(朝・5時〜11時)", label_en: "Morning (05-11)", category: "home" },
VoiceKind { name: "time_noon", label: "時間帯(昼・11時〜17時)", label_en: "Daytime (11-17)", category: "home" },
VoiceKind { name: "time_evening", label: "時間帯(夕方・17時〜23時)", label_en: "Evening (17-23)", category: "home" },
VoiceKind { name: "time_night", label: "時間帯(夜・23時〜5時)", label_en: "Night (23-05)", category: "home" },
// --- home screen: season (the game's own month bands) ---
VoiceKind { name: "season_spring", label: "季節(春・3〜5月)", label_en: "Spring (March-May)", category: "home" },
VoiceKind { name: "season_summer", label: "季節(夏・6〜8月)", label_en: "Summer (June-August)", category: "home" },
VoiceKind { name: "season_autumn", label: "季節(秋・9〜11月)", label_en: "Autumn (September-November)", category: "home" },
VoiceKind { name: "season_winter", label: "季節(冬・12〜2月)", label_en: "Winter (December-February)", category: "home" },
// --- home screen: calendar days. On a day that matches, the home screen
// asks for NOTHING ELSE (a matching day line pre-empts the whole pool
// unless the player taps), so these are the "special occasion" lines
VoiceKind { name: "day_0101", label: "記念日(1月1日・お正月)", label_en: "January 1 (New Year's Day)", category: "home" },
VoiceKind { name: "day_0203", label: "記念日(2月3日・節分)", label_en: "February 3 (Setsubun)", category: "home" },
VoiceKind { name: "day_0214", label: "記念日(2月14日・バレンタイン)", label_en: "February 14 (Valentine's Day)", category: "home" },
VoiceKind { name: "day_0303", label: "記念日(3月3日・ひな祭り)", label_en: "March 3 (Hinamatsuri)", category: "home" },
VoiceKind { name: "day_0314", label: "記念日(3月14日・ホワイトデー)", label_en: "March 14 (White Day)", category: "home" },
VoiceKind { name: "day_0505", label: "記念日(5月5日・こどもの日)", label_en: "May 5 (Children's Day)", category: "home" },
VoiceKind { name: "day_0707", label: "記念日(7月7日・七夕)", label_en: "July 7 (Tanabata)", category: "home" },
VoiceKind { name: "day_0717", label: "記念日(7月第3月曜・海の日)", label_en: "Marine Day (third Monday of July)", category: "home" },
VoiceKind { name: "day_1015", label: "記念日(10月15日)", label_en: "October 15", category: "home" },
VoiceKind { name: "day_1031", label: "記念日(10月31日・ハロウィン)", label_en: "October 31 (Halloween)", category: "home" },
VoiceKind { name: "day_1225", label: "記念日(12月25日・クリスマス)", label_en: "December 25 (Christmas)", category: "home" },
VoiceKind { name: "day_1231", label: "記念日(12月31日・大晦日)", label_en: "December 31 (New Year's Eve)", category: "home" },
// --- home screen: lines keyed to what the account has waiting ---
VoiceKind { name: "advice_story", label: "未読ストーリーあり(おすすめ)", label_en: "Unread story waiting (advice line)", category: "home" },
VoiceKind { name: "trigger_story", label: "未読ストーリーあり(反応)", label_en: "Unread story waiting (trigger line)", category: "home" },
VoiceKind { name: "advice_mission", label: "デイリーミッション報酬未受取", label_en: "Unclaimed daily mission reward", category: "home" },
VoiceKind { name: "advice_live", label: "LPが足りている(ライブ)", label_en: "Enough LP to play a live", category: "home" },
VoiceKind { name: "advice_lp", label: "LP半分以下(回復のすすめ)", label_en: "LP below half, recovery affordable", category: "home" },
VoiceKind { name: "trigger_shop", label: "LP半分以下(ショップ)", label_en: "LP below half, recovery affordable (shop line)", category: "home" },
VoiceKind { name: "advice_lesson", label: "レッスン(未カンスト)", label_en: "A unit is not fully levelled", category: "home" },
VoiceKind { name: "advice_present", label: "プレゼント未受取", label_en: "Unclaimed present waiting", category: "home" },
VoiceKind { name: "advice_gacha", label: "無料スカウト可能", label_en: "The daily free scouting is available", category: "home" },
VoiceKind { name: "advice_infomation", label: "新着お知らせあり", label_en: "A new announcement is up", category: "home" },
VoiceKind { name: "advice_event", label: "イベント開催中", label_en: "An event is running", category: "home" },
VoiceKind { name: "trigger_friend", label: "イベント中・フレンド", label_en: "An event is running and a friend is active", category: "home" }
];
const MAX_VOICE_VARIANTS: usize = 9;
const MAX_VOICE_BYTES: usize = 4 * 1024 * 1024;
@@ -299,6 +387,79 @@ lazy_static! {
}
rv
};
// NERF_CUSTOM_CARDS: per-rarity (hp, smile, cool, pure) ceilings set ~200
// below the median official card of that rarity (official medians: R ~2160,
// SR ~4010, UR ~4366 -> caps 2000 / 3800 / 4200), so a nerfed custom card
// stays under a typical official one. Skill magnitudes nerf to 4/5 of the
// official ranges
static ref NERF_STAT_CAPS: HashMap<i64, (i64, i64, i64, i64)> = {
let mut rv: HashMap<i64, (i64, i64, i64, i64)> = HashMap::new();
for (&rarity, (hp, _, _, _)) in &*STAT_CAPS {
let cap = match rarity {
1 => 2000,
2 => 3800,
_ => 4200,
};
rv.insert(rarity, (*hp, cap, cap, cap));
}
rv
};
}
// The nerf band. Read from the runtime flag rather than get_args(): the
// host-config path (set_nerf_custom_cards, mirrored by overlay_args) is the
// one the lib mode and the tests reach, and get_args() chokes on the test
// harness's own CLI arguments (the same reason set_time is avoided in tests)
pub fn nerf_active() -> bool {
crate::runtime::get_nerf_custom_cards()
}
// Skill magnitudes nerf to 4/5 of the official range
fn nerf_scale(value: i64) -> i64 {
if value > 0 { (value * 4 / 5).max(1) } else { value * 4 / 5 }
}
// A stored skill array element above the cap moves down to the cap; below it
// is untouched
fn nerf_clamp_element(value: &mut JsonValue, cap: i64) {
if cap > 0 && value.as_i64().unwrap_or(0) > cap {
*value = cap.into();
}
}
// NERF_CUSTOM_CARDS clamps a card's stored stats and skill magnitudes to the
// nerf band WITHOUT touching the db row - the db keeps the owner's values,
// every response carries the clamped ones. No-op below the caps, so an
// ordinary card passes through unchanged
fn nerf_clamp_card(card: &mut JsonValue) {
let rarity = card["rarity"].as_i64().unwrap_or(0);
if let Some((hp, smile, cool, pure)) = NERF_STAT_CAPS.get(&rarity) {
for (key, cap) in [("hp", *hp), ("smile", *smile), ("cool", *cool), ("pure", *pure)] {
nerf_clamp_element(&mut card[key], cap);
}
}
if card["skill"].is_null() {
return;
}
let effect_type = card["skill"]["effect_type"].as_i64().unwrap_or(0);
let trigger = card["skill"]["trigger"].as_i64().unwrap_or(0);
let (_, value_max, _) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(0, 0, "official"));
let (_, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(0, 0));
let ((_, prob_max), (_, ms_max)) = *SKILL_SCALAR_RANGES;
for (key, cap) in [
("trigger_value", nerf_scale(trigger_max)),
("probability", nerf_scale(prob_max)),
("effective_milli_secs", nerf_scale(ms_max)),
("effective_values", nerf_scale(value_max))
] {
if !card["skill"][key].is_array() {
continue;
}
for value in card["skill"][key].members_mut() {
nerf_clamp_element(value, cap);
}
}
}
// Game endpoints (/api scope, standard envelope)
@@ -438,10 +599,15 @@ pub fn strip_unsupported(user: &mut JsonValue) {
// The concrete upload bounds, served to the webui so the form can enforce
// them client-side (sliders/radios) and reject out-of-range values instantly
pub fn upload_limits() -> JsonValue {
let nerfed = nerf_active();
let mut stat_caps = object!{};
let mut skill_levels = object!{};
for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX {
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0));
let caps = if nerfed {
NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
} else {
STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
};
stat_caps[rarity.to_string()] = object!{
"hp": caps.0,
"smile": caps.1,
@@ -466,11 +632,12 @@ pub fn upload_limits() -> JsonValue {
}
// The skill magnitude envelopes, so the form's number inputs carry real
// min/max and reject out-of-range (and e-notation) before submitting
let scale = |v: i64| if nerfed { nerf_scale(v) } else { v };
let mut effect_value_ranges = object!{};
for (effect, (min, max, source)) in SKILL_VALUE_RANGES.iter() {
effect_value_ranges[effect.to_string()] = object!{
"min": *min,
"max": *max,
"max": scale(*max),
"source": *source
};
}
@@ -478,10 +645,23 @@ pub fn upload_limits() -> JsonValue {
for (trigger, (min, max)) in SKILL_TRIGGER_RANGES.iter() {
trigger_value_ranges[trigger.to_string()] = object!{
"min": *min,
"max": *max
"max": scale(*max)
};
}
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
let prob_max = scale(prob_max);
let ms_max = scale(ms_max);
// The voiceline moments, so the form renders exactly the kinds this build
// accepts instead of carrying its own copy of the list
let mut voice_kinds = array![];
for kind in VOICE_KINDS {
voice_kinds.push(object!{
"name": kind.name,
"label": kind.label,
"label_en": kind.label_en,
"category": kind.category
}).unwrap();
}
object!{
"stat_caps": stat_caps,
"skill_levels": skill_levels,
@@ -498,7 +678,15 @@ pub fn upload_limits() -> JsonValue {
"probability": { "min": prob_min, "max": prob_max },
"milli_secs": { "min": ms_min, "max": ms_max }
},
"min_source_dim": art::MIN_SOURCE_DIM
"min_source_dim": art::MIN_SOURCE_DIM,
"max_file_bytes": MAX_FILE_BYTES,
"max_request_bytes": MAX_REQUEST_BYTES,
"max_cards_per_user": MAX_CARDS_PER_USER,
"max_bytes_per_user": MAX_BYTES_PER_USER,
"voice_kinds": voice_kinds,
"max_voice_variants": MAX_VOICE_VARIANTS,
"max_voice_bytes": MAX_VOICE_BYTES,
"max_voice_seconds": MAX_VOICE_SECONDS
}
}
@@ -513,17 +701,30 @@ pub fn owned_runtime_ids(user: &JsonValue) -> Vec<i64> {
// The catalog is filtered per requesting user: everyone gets the published
// cards, the owner additionally gets their drafts, and a game account that
// already owns a card keeps resolving it even if it was since unpublished
async fn list(Login(key): Login) -> impl Responder {
async fn list(req: HttpRequest, Login(key): Login) -> impl Responder {
if disabled() {
// As if the endpoint doesn't exist - the client treats this as feature-off
return Api(None);
}
let user = userdata::get_acc(&key);
let uid = user["user"]["id"].as_i64().unwrap();
let cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user));
let characters = database::get_characters_for_cards(uid, &cards);
let mut cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user));
if nerf_active() {
for card in cards.members_mut() {
nerf_clamp_card(card);
}
}
let mut characters = database::get_characters_for_cards(uid, &cards);
if global::client_protocol_version(&req) < crate::router::custom_group::PROTOCOL_VERSION {
for character in characters.members_mut() {
if character["master_group_id"].as_i64().unwrap_or(0) >= custom_group::FIRST_ID {
character["master_group_id"] = CHARACTER_GROUP_ID.into();
}
}
}
Api(Some(object!{
"revision": database::get_revision(),
"groups": custom_group::list(),
"characters": characters,
"cards": cards
}))
@@ -559,6 +760,17 @@ async fn data(req: HttpRequest) -> HttpResponse {
return HttpResponse::NotFound().finish();
};
match fs::read(asset_path(&relative)) {
// Art lives at FIXED per-card filenames ({kind}_{variant}.png) that an
// in-place edit overwrites, so between the file write and the catalog
// update the index still points an old md5 at bytes that are no longer its
// own. The client caches whatever it downloads under the md5 it asked for
// and never re-checks, so serving those bytes would poison its cache
// permanently. The index is a hint; these bytes are the answer only if
// they hash to the request. A mismatch is the same 404 a stale md5 already
// gets, and the client re-downloads under the md5 the catalog now carries
Ok(body) if !hash.eq_ignore_ascii_case(&format!("{:x}", md5::compute(&body))) => {
HttpResponse::NotFound().finish()
},
Ok(body) => {
HttpResponse::Ok()
.insert_header(ContentType::png())
@@ -840,11 +1052,29 @@ fn write_art(dir: &str, pending: &[PendingArt]) -> Result<(), String> {
fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue, Vec<(String, Vec<u8>)>), String> {
let mut rv = array![];
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
// An unknown moment is a typo, and silently ignoring it (what happens to
// every other unrecognised multipart field) would show the uploader a clip
// that uploaded fine and a line that never appeared. An out-of-range INDEX
// stays ignored - that is a full slot list, not a misspelling
for key in fields.keys() {
let Some(rest) = key.strip_prefix("voice_") else { continue; };
let rest = ["_text_en", "_text", "_delete"].iter()
.find_map(|suffix| rest.strip_suffix(suffix))
.unwrap_or(rest);
let named_moment = match rest.rsplit_once('_') {
Some((kind, index)) => index.parse::<usize>().is_ok() && VOICE_KINDS.iter().any(|k| k.name == kind),
None => false
};
if !named_moment {
return Err(format!("'{}' is not a voiceline field", key));
}
}
for kind in VOICE_KINDS {
let kind = kind.name;
let mut lines: Vec<JsonValue> = Vec::new();
for index in 1..=MAX_VOICE_VARIANTS {
let base = format!("voice_{}_{}", kind, index);
let stored_line = stored_voice.members().find(|line| line["kind"] == *kind && line["index"] == index);
let stored_line = stored_voice.members().find(|line| line["kind"] == kind && line["index"] == index);
if field_flag(fields, &format!("{}_delete", base)) {
if file_of(fields, &base).is_some() {
return Err(format!("'{}': cannot both replace and delete the same line", base));
@@ -869,7 +1099,7 @@ fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue
}
let clip = audio::process_one_shot(bytes, MAX_VOICE_SECONDS).map_err(|e| format!("'{}': {}", base, e))?;
lines.push(object!{
"kind": *kind,
"kind": kind,
"index": 0,
"md5": clip.md5.clone(),
"size": clip.bytes.len(),
@@ -879,7 +1109,7 @@ fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue
files.push((clip.md5, clip.bytes));
} else if let Some(stored_line) = stored_line {
lines.push(object!{
"kind": *kind,
"kind": kind,
"index": 0,
"md5": stored_line["md5"].clone(),
"size": stored_line["size"].clone(),
@@ -917,9 +1147,12 @@ fn write_voice(master_character_id: i64, files: &[(String, Vec<u8>)]) -> Result<
// (their old catalog md5 404s, exactly like replaced art)
fn gc_voice(master_character_id: i64, old_voice: &JsonValue, new_voice: &JsonValue) {
for old in old_voice.members() {
let md5 = old["md5"].to_string();
if !md5.is_empty() && !new_voice.members().any(|line| line["md5"] == old["md5"]) {
let _ = fs::remove_file(voice_path(master_character_id, &md5));
// JsonValue::to_string renders Null as the literal "null", which an
// is_empty() guard happily passes; only exactly 32 hex characters is a
// hash (the same test custom_3dmv's blob GC uses)
let Some(md5) = old["md5"].as_str().filter(|md5| md5.len() == 32 && md5.chars().all(|c| c.is_ascii_hexdigit())) else { continue; };
if !new_voice.members().any(|line| line["md5"] == old["md5"]) {
let _ = fs::remove_file(voice_path(master_character_id, md5));
}
}
}
@@ -983,13 +1216,21 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
// Note the official HP scale before assuming a bug report: hp is a tiny
// per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4)
let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0));
// NERF_CUSTOM_CARDS drops the ceiling from the official rarity max to the
// nerf band (~200 below the official median) for this upload
let caps = if nerf_active() {
NERF_STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
} else {
STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0))
};
let nerfed = nerf_active();
let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)];
let mut values = Vec::new();
for (key, cap) in stats {
let value = number_of(fields, key, stored, key);
if !(1..=cap).contains(&value) {
return Err(format!("{} must be between 1 and {} for a {} card (the official {} range)", key, cap, rarity_name, rarity_name));
let range = if nerfed { "nerfed" } else { "official" };
return Err(format!("{} must be between 1 and {} for a {} card (the {} {} range)", key, cap, rarity_name, range, rarity_name));
}
values.push(value);
}
@@ -1039,17 +1280,22 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
// effective_milli_secs may be 1 long). MAGNITUDES are clamped to the
// ranges the shipped skill rows actually use - a 40-second buff and a
// 1e8 score-up both made it through before these bounds existed
// NERF_CUSTOM_CARDS drops the magnitude ceilings to 4/5 of the official
// range; the floor stays the official one so a nerfed card can never be
// weaker than the weakest official row of that effect
let nerfed = nerf_active();
let (trigger_min, trigger_max) = *SKILL_TRIGGER_RANGES.get(&trigger).unwrap_or(&(1, 1));
let (value_min, value_max, value_source) = *SKILL_VALUE_RANGES.get(&effect_type).unwrap_or(&(1, 1, "official"));
let ((prob_min, prob_max), (ms_min, ms_max)) = *SKILL_SCALAR_RANGES;
let range_word = if nerfed { "nerfed" } else { "official" };
let bounds = [
("skill_trigger_value", "trigger_value", trigger_min, trigger_max,
format!("for trigger {} (the official range)", trigger)),
("skill_probability", "probability", prob_min, prob_max,
format!("({}%-{}%, the official range)", prob_min / 1000, prob_max / 1000)),
("skill_effective_milli_secs", "effective_milli_secs", ms_min, ms_max,
String::from("milliseconds (the official range)")),
("skill_effective_values", "effective_values", value_min, value_max,
("skill_trigger_value", "trigger_value", trigger_min, if nerfed { nerf_scale(trigger_max) } else { trigger_max },
format!("for trigger {} (the {} range)", trigger, range_word)),
("skill_probability", "probability", prob_min, if nerfed { nerf_scale(prob_max) } else { prob_max },
format!("({}%-{}%, the {} range)", prob_min / 1000, (if nerfed { nerf_scale(prob_max) } else { prob_max }) / 1000, range_word)),
("skill_effective_milli_secs", "effective_milli_secs", ms_min, if nerfed { nerf_scale(ms_max) } else { ms_max },
format!("milliseconds (the {} range)", range_word)),
("skill_effective_values", "effective_values", value_min, if nerfed { nerf_scale(value_max) } else { value_max },
format!("for effect_type {} ({}) (the {} range)", effect_type, EFFECT_NAMES[effect_type as usize], value_source))
];
let mut arrays: HashMap<&str, Vec<i64>> = HashMap::new();
@@ -1115,6 +1361,13 @@ fn valid_color(color: &str) -> bool {
// Every column the uploader never supplies is forced to the value all 172
// imported characters carry. Numbers, not enum names
fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
let group_text = text_of(fields, "character_master_group_id", stored, "master_group_id");
let group_id = if group_text.is_empty() { CHARACTER_GROUP_ID } else {
group_text.parse::<i64>().map_err(|_| String::from("Invalid group ID"))?
};
if group_id != CHARACTER_GROUP_ID && !custom_group::exists(group_id) {
return Err(String::from("Custom group does not exist"));
}
for (key, label) in [
("character_name", "Character name"),
("character_name_en", "Character English name"),
@@ -1161,7 +1414,7 @@ fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue
"category": CHARACTER_CATEGORY_OTHER,
"school_grade": CHARACTER_SCHOOL_GRADE,
"chara_category": CHARACTER_CHARA_CATEGORY,
"master_group_id": CHARACTER_GROUP_ID,
"master_group_id": group_id,
"sprite_name": "",
"display_order": master_character_id,
"height": text("height"),
@@ -1215,15 +1468,31 @@ pub fn create_card(uid: i64, fields: &Fields) -> Result<i64, String> {
let mut card = build_card(master_card_id, master_character_id, fields, &object!{})?;
card["art"] = merge_art(&array![], &card_art);
check_quota(uid, database::card_bytes(&card), 0, 0)?;
write_art(&card_dir(master_card_id), &card_art)?;
database::insert_card(master_card_id, master_character_id, uid, &card, published, obtainable);
database::insert_card(master_card_id, master_character_id, uid, &card, published, obtainable)
.map_err(|e| format!("Could not store the card: {}", e))?;
database::bump_revision();
drop(lock);
Ok(master_card_id)
}
// Per-account storage quota over this account's cards AND characters. The
// excluded ids are the entity being replaced by an in-place edit - its stored
// size drops out and `adding` (the resulting size) replaces it
fn check_quota(uid: i64, adding: i64, excluded_card_id: i64, excluded_character_id: i64) -> Result<(), String> {
let used = database::owner_bytes(uid, excluded_card_id, excluded_character_id);
if used + adding > MAX_BYTES_PER_USER {
return Err(format!(
"This upload would put your uploads at {} MB, over the {} MB per-account limit - delete a card or character first",
(used + adding) / (1024 * 1024), MAX_BYTES_PER_USER / (1024 * 1024)
));
}
Ok(())
}
// Edit a card in place. The master_card_id - and everything derived from it:
// master_skill_id, illust ids - stays the same, so a player who owns the card
// keeps owning the same card. master_character_id is fixed too: repointing it
@@ -1235,13 +1504,20 @@ pub fn update_card(uid: i64, master_card_id: i64, fields: &Fields) -> Result<(),
if !can_manage(uid, owner) {
return Err(String::from("You do not have permission to edit this card"));
}
let stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?;
let mut stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?;
// An edit's "unchanged" fallbacks read from the stored blob, so the
// stored reference itself is brought into the nerf band first - an
// over-cap value the form leaves untouched stays over cap in the dry-run
if nerf_active() {
nerf_clamp_card(&mut stored);
}
let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0);
// Field validation first (cheap), art processing second - fail fast
let mut card = build_card(master_card_id, master_character_id, fields, &stored)?;
let card_art = collect_card_art(fields, false)?;
card["art"] = merge_art(&stored["art"], &card_art);
check_quota(owner, database::card_bytes(&card), master_card_id, 0)?;
let lock = lock_onto_mutex!(UPLOAD_LOCK);
write_art(&card_dir(master_card_id), &card_art)?;
@@ -1313,10 +1589,12 @@ pub fn create_character(uid: i64, fields: &Fields) -> Result<i64, String> {
if !voice.is_empty() {
character["voice"] = voice;
}
check_quota(uid, database::character_bytes(&character), 0, 0)?;
write_art(&character_dir(master_character_id), &character_art)?;
write_voice(master_character_id, &voice_files)?;
database::insert_character(master_character_id, uid, &character);
database::insert_character(master_character_id, uid, &character)
.map_err(|e| format!("Could not store the character: {}", e))?;
database::bump_revision();
drop(lock);
@@ -1340,6 +1618,7 @@ pub fn update_character(uid: i64, master_character_id: i64, fields: &Fields) ->
if !voice.is_empty() {
character["voice"] = voice.clone();
}
check_quota(owner, database::character_bytes(&character), 0, master_character_id)?;
let lock = lock_onto_mutex!(UPLOAD_LOCK);
write_art(&character_dir(master_character_id), &character_art)?;
@@ -1376,6 +1655,44 @@ pub fn delete_character(uid: i64, master_character_id: i64) -> Result<(), String
Ok(())
}
// Every card this account uploaded, gone - called from userdata::delete_account,
// so a purged uploader leaves no catalog row resolving an owner id that no longer
// exists (browse renders an uploader name for every row). Player copies of the
// dead cards are wiped lazily on each account's next userdata pull, exactly like
// an owner-initiated delete.
//
// Characters go with them only when nothing else references them: a custom
// character that still backs SOMEONE ELSE'S card cannot be deleted without
// serving a dangling master_character_id, which the client throws on. Those stay,
// ownerless, which is the same trade delete_character already makes
pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() {
return;
}
let cards = database::card_ids_for_owner(uid);
let characters = database::character_ids_for_owner(uid);
if cards.is_empty() && characters.is_empty() {
return;
}
let lock = lock_onto_mutex!(UPLOAD_LOCK);
for master_card_id in cards {
database::delete_card(master_card_id);
let _ = fs::remove_dir_all(card_dir(master_card_id));
}
for master_character_id in characters {
if database::cards_using_character(master_character_id) > 0 {
continue;
}
database::delete_character(master_character_id);
let _ = fs::remove_dir_all(character_dir(master_character_id));
}
database::bump_revision();
drop(lock);
}
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
if disabled() {
return HttpResponse::NotFound().finish();
@@ -1387,12 +1704,16 @@ async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match create_card(uid, &fields) {
Ok(master_card_id) => send_json(object!{
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
// that also has to keep serving the game API
match web::block(move || create_card(uid, &fields)).await {
Ok(Ok(master_card_id)) => send_json(object!{
result: "OK",
master_card_id: master_card_id
}),
Err(e) => webui::error(&e)
Ok(Err(e)) => webui::error(&e),
Err(_) => webui::error("The upload could not be processed")
}
}
@@ -1408,12 +1729,16 @@ async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
Err(e) => return webui::error(&e)
};
let master_card_id = field_str(&fields, "master_card_id").parse::<i64>().unwrap_or(0);
match update_card(uid, master_card_id, &fields) {
Ok(()) => send_json(object!{
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
// that also has to keep serving the game API
match web::block(move || update_card(uid, master_card_id, &fields)).await {
Ok(Ok(())) => send_json(object!{
result: "OK",
master_card_id: master_card_id
}),
Err(e) => webui::error(&e)
Ok(Err(e)) => webui::error(&e),
Err(_) => webui::error("The edit could not be processed")
}
}
@@ -1461,12 +1786,16 @@ async fn character_create(req: HttpRequest, payload: Multipart) -> HttpResponse
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match create_character(uid, &fields) {
Ok(master_character_id) => send_json(object!{
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
// that also has to keep serving the game API
match web::block(move || create_character(uid, &fields)).await {
Ok(Ok(master_character_id)) => send_json(object!{
result: "OK",
master_character_id: master_character_id
}),
Err(e) => webui::error(&e)
Ok(Err(e)) => webui::error(&e),
Err(_) => webui::error("The upload could not be processed")
}
}
@@ -1482,12 +1811,16 @@ async fn character_update(req: HttpRequest, payload: Multipart) -> HttpResponse
Err(e) => return webui::error(&e)
};
let master_character_id = field_str(&fields, "master_character_id").parse::<i64>().unwrap_or(0);
match update_character(uid, master_character_id, &fields) {
Ok(()) => send_json(object!{
// Deriving 14 Lanczos3 PNGs up to 2048x1260 (and transcoding voicelines) is
// seconds of CPU: it belongs on the blocking pool, not on the actix worker
// that also has to keep serving the game API
match web::block(move || update_character(uid, master_character_id, &fields)).await {
Ok(Ok(())) => send_json(object!{
result: "OK",
master_character_id: master_character_id
}),
Err(e) => webui::error(&e)
Ok(Err(e)) => webui::error(&e),
Err(_) => webui::error("The edit could not be processed")
}
}
@@ -1514,9 +1847,15 @@ async fn mine(req: HttpRequest) -> HttpResponse {
let Some(uid) = get_session_uid(&req) else {
return webui::error("Not logged in");
};
let mut cards = database::get_cards_by_owner(uid);
if nerf_active() {
for card in cards.members_mut() {
nerf_clamp_card(card);
}
}
send_json(object!{
result: "OK",
cards: database::get_cards_by_owner(uid),
cards: cards,
characters: database::get_characters_by_owner(uid)
})
}
@@ -1532,6 +1871,9 @@ async fn browse(_req: HttpRequest) -> HttpResponse {
for card in cards.members_mut() {
card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone();
card.remove("owner_id");
if nerf_active() {
nerf_clamp_card(card);
}
}
let characters = database::get_characters_for_cards(0, &cards);
send_json(object!{
@@ -1813,7 +2155,7 @@ pub mod tests {
let mut edit = Fields::new();
edit.insert(String::from("voice_result_bond_1"), test_wav(31.0, 6));
let err = with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit)).unwrap_err();
assert!(err.contains("voice_result_bond_1") && err.contains("maximum is 30"), "{}", err);
assert!(err.contains("voice_result_bond_1") && err.contains("30 second maximum"), "{}", err);
let mut edit = Fields::new();
edit.insert(String::from("voice_result_bond_1"), b"definitely not audio".to_vec());
assert!(with_permissions(4010, &[permissions::CARD_UPLOAD], || update_character(4010, id, &edit))
@@ -1823,6 +2165,145 @@ pub mod tests {
wipe(4010);
}
// The home-screen moments ride the exact same upload path as the live/skill
// ones: real create + edit, transcode, content addressing, renumbering.
// day_1225 is the interesting one - the client turns it into a row carrying
// a date_condition id rather than 0
#[test]
fn home_voicelines_upload_through_the_real_path() {
let _lock = crate::runtime::lock_test_data_path();
wipe(4011);
let mut fields = character_fields();
fields.insert(String::from("voice_random_1"), test_wav(1.0, 11));
field(&mut fields, "voice_random_1_text", "おはよう!");
field(&mut fields, "voice_random_1_text_en", "Morning!");
fields.insert(String::from("voice_random_3"), test_wav(1.0, 12));
fields.insert(String::from("voice_touch_1"), test_wav(0.5, 13));
fields.insert(String::from("voice_time_morning_1"), test_wav(0.5, 14));
fields.insert(String::from("voice_day_1225_1"), test_wav(0.5, 15));
fields.insert(String::from("voice_trigger_friend_1"), test_wav(0.5, 16));
// Still ignored: a real moment, an index past the variant cap
fields.insert(String::from("voice_random_10"), test_wav(1.0, 17));
let id = with_permissions(4011, &[permissions::CARD_UPLOAD], || create_character(4011, &fields).unwrap());
let character = database::get_character(id).unwrap();
let voice = &character["voice"];
assert_eq!(voice.len(), 6);
let kinds: Vec<String> = voice.members().map(|line| line["kind"].to_string()).collect();
for kind in ["random", "touch", "time_morning", "day_1225", "trigger_friend"] {
assert!(kinds.contains(&String::from(kind)), "{} missing from {:?}", kind, kinds);
}
// The sparse random indexes renumbered to 1 and 2, captions intact
let random: Vec<&JsonValue> = voice.members().filter(|line| line["kind"] == "random").collect();
assert_eq!(random.len(), 2);
assert_eq!(random[0]["index"].as_i64(), Some(1));
assert_eq!(random[1]["index"].as_i64(), Some(2));
assert_eq!(random[0]["text"].as_str(), Some("おはよう!"));
assert_eq!(random[0]["text_en"].as_str(), Some("Morning!"));
for line in voice.members() {
let md5 = line["md5"].to_string();
let bytes = fs::read(voice_path(id, &md5)).unwrap();
assert!(bytes.starts_with(b"OggS"), "transcoded to ogg");
assert_eq!(database::find_voice_by_md5(&md5), Some(format!("characters/{}/voice/{}.ogg", id, md5)));
}
// Editing one home moment leaves the others alone
let touch_md5 = voice.members().find(|line| line["kind"] == "touch").unwrap()["md5"].to_string();
let mut edit = Fields::new();
field(&mut edit, "voice_day_1225_1_text", "メリークリスマス!");
edit.insert(String::from("voice_touch_2"), test_wav(0.5, 18));
with_permissions(4011, &[permissions::CARD_UPLOAD], || update_character(4011, id, &edit).unwrap());
let after = database::get_character(id).unwrap();
assert_eq!(after["voice"].len(), 7);
assert_eq!(after["voice"].members().find(|line| line["kind"] == "day_1225").unwrap()["text"].as_str(),
Some("メリークリスマス!"));
assert_eq!(after["voice"].members()
.find(|line| line["kind"] == "touch" && line["index"] == 1).unwrap()["md5"].to_string(), touch_md5);
// The 30-second and rich-text rules apply to the new moments too
let mut edit = Fields::new();
edit.insert(String::from("voice_time_night_1"), test_wav(31.0, 19));
let err = with_permissions(4011, &[permissions::CARD_UPLOAD], || update_character(4011, id, &edit)).unwrap_err();
assert!(err.contains("voice_time_night_1") && err.contains("30 second maximum"), "{}", err);
let mut edit = Fields::new();
field(&mut edit, "voice_touch_1_text", "<color=red>x");
assert!(with_permissions(4011, &[permissions::CARD_UPLOAD], || update_character(4011, id, &edit))
.unwrap_err().contains("<color>"));
wipe(4011);
}
// A misspelled moment must not upload as silence-that-never-plays
#[test]
fn an_unknown_voice_kind_is_rejected() {
let _lock = crate::runtime::lock_test_data_path();
wipe(4012);
let reject = |key: &str| {
let mut fields = character_fields();
fields.insert(String::from(key), test_wav(0.5, 21));
let err = with_permissions(4012, &[permissions::CARD_UPLOAD], || create_character(4012, &fields)).unwrap_err();
assert!(err.contains(key) && err.contains("not a voiceline field"), "{}: {}", key, err);
};
reject("voice_time_mornng_1");
reject("voice_day_0102_1");
reject("voice_live_start");
reject("voice_random_x");
// Captions and delete flags go through the same check
let mut fields = character_fields();
field(&mut fields, "voice_seaon_spring_1_text", "x");
let err = with_permissions(4012, &[permissions::CARD_UPLOAD], || create_character(4012, &fields)).unwrap_err();
assert!(err.contains("voice_seaon_spring_1_text"), "{}", err);
// ...and a correctly named one still goes through
let mut fields = character_fields();
fields.insert(String::from("voice_season_spring_1"), test_wav(0.5, 22));
let id = with_permissions(4012, &[permissions::CARD_UPLOAD], || create_character(4012, &fields).unwrap());
assert_eq!(database::get_character(id).unwrap()["voice"][0]["kind"].as_str(), Some("season_spring"));
wipe(4012);
}
// The webui renders whatever this serves; a kind that only exists in one of
// the two lists is the bug this endpoint exists to prevent
#[test]
fn the_limits_endpoint_lists_the_voice_kinds() {
let limits = upload_limits();
assert_eq!(limits["max_voice_variants"].as_usize(), Some(MAX_VOICE_VARIANTS));
assert_eq!(limits["max_voice_bytes"].as_usize(), Some(MAX_VOICE_BYTES));
assert_eq!(limits["max_voice_seconds"].as_f64(), Some(MAX_VOICE_SECONDS));
let served = &limits["voice_kinds"];
assert_eq!(served.len(), VOICE_KINDS.len());
for (entry, kind) in served.members().zip(VOICE_KINDS) {
assert_eq!(entry["name"].as_str(), Some(kind.name));
assert_eq!(entry["label"].as_str(), Some(kind.label));
assert_eq!(entry["label_en"].as_str(), Some(kind.label_en));
assert_eq!(entry["category"].as_str(), Some(kind.category));
assert!(!kind.label.is_empty() && !kind.label_en.is_empty(), "{} needs both labels", kind.name);
assert!(["home", "live", "result", "skill"].contains(&kind.category), "{}: {}", kind.name, kind.category);
}
// Names are the multipart infix and the client's switch arm: unique,
// snake_case, and free of the separators the field parser splits on
let mut names: Vec<&str> = VOICE_KINDS.iter().map(|kind| kind.name).collect();
names.sort_unstable();
let count = names.len();
names.dedup();
assert_eq!(names.len(), count, "duplicate voice kind name");
for kind in VOICE_KINDS {
assert!(kind.name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), "{}", kind.name);
// The field parser strips these suffixes before splitting off the index
assert!(!kind.name.ends_with("_text") && !kind.name.ends_with("_text_en") && !kind.name.ends_with("_delete"), "{}", kind.name);
// ...and splits at the LAST underscore, so "{another kind}_{number}"
// would be two readings of the same field name
if let Some((head, tail)) = kind.name.rsplit_once('_') {
assert!(tail.parse::<usize>().is_err() || !VOICE_KINDS.iter().any(|other| other.name == head),
"{} is ambiguous with {}", kind.name, head);
}
}
}
// Diagnostic + sanity guard for the derived skill magnitude envelopes
#[test]
fn skill_ranges_derive_from_shipped_rows() {
@@ -1846,6 +2327,8 @@ pub mod tests {
assert!(ms_min >= 500 && ms_max < 40_000, "40s buffs must be out of range");
let (_, value_max, _) = SKILL_VALUE_RANGES[&4];
assert!(value_max < 100_000_000, "1e8 score-ups must be out of range");
let (repeat_min, repeat_max, _) = SKILL_VALUE_RANGES[&11];
assert_eq!((repeat_min, nerf_scale(repeat_max)), (1, 1));
}
// A full create: derived ids, pinned columns, art md5s and the catalog
@@ -2135,6 +2618,64 @@ pub mod tests {
wipe(4004);
}
// NERF_CUSTOM_CARDS: an over-cap upload is rejected, an over-cap value the
// owner already stored is clamped in every served view (db row untouched),
// and the skill ceiling drops to 4/5 of the official range
#[test]
fn nerf_band_caps_new_and_existing_cards() {
let _lock = crate::runtime::lock_test_data_path();
wipe(5005);
crate::runtime::set_nerf_custom_cards(false);
// Un-nerfed: the official rarity ceiling (4110 for R) is reachable
let fields = base_fields();
let id = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &fields).unwrap());
let mut over = base_fields();
field(&mut over, "smile", "4110");
with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &over).unwrap());
assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110));
// Nerfed: a new 4110 upload is refused at the nerf band (2000 for R)...
crate::runtime::set_nerf_custom_cards(true);
let mut high = base_fields();
field(&mut high, "smile", "4110");
let err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &high)).unwrap_err();
assert!(err.contains("smile must be between 1 and 2000 for a R card") && err.contains("nerfed"), "{}", err);
// Boundary: 2000 accepted, 2001 refused. The fixture's trigger values
// (25/24/24) sit in the official 13-30 but above the nerfed 13-24, so
// a nerfed-boundary upload carries a trigger value legal in both bands
let mut at_cap = base_fields();
field(&mut at_cap, "smile", "2000");
field(&mut at_cap, "skill_trigger_value", "20/20/20");
let at_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &at_cap));
assert!(at_cap_err.is_ok(), "2000 should be accepted: {:?}", at_cap_err.err());
let mut over_cap = base_fields();
field(&mut over_cap, "smile", "2001");
let over_cap_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || create_card(5005, &over_cap)).unwrap_err();
assert!(over_cap_err.contains("must be between 1 and 2000"), "{}", over_cap_err);
// ...and the ALREADY-stored 4110 is clamped to the cap in the served view
let mut view = database::get_card(id).unwrap();
nerf_clamp_card(&mut view);
assert_eq!(view["smile"].as_i64(), Some(2000));
// while the db keeps the owner's stored value
assert_eq!(database::get_card(id).unwrap()["smile"].as_i64(), Some(4110));
// Skill magnitudes: the ceiling is 4/5 of the official max (439 -> 351)
crate::runtime::set_nerf_custom_cards(false);
let mut strong = base_fields();
field(&mut strong, "skill_effective_values", "439/439/439");
field(&mut strong, "skill_trigger_value", "20/20/20");
with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong).unwrap());
crate::runtime::set_nerf_custom_cards(true);
let skill_err = with_permissions(5005, &[permissions::CARD_UPLOAD], || update_card(5005, id, &strong)).unwrap_err();
// 439 * 4/5 = 351: the official 91-439 range is capped at 351
assert!(skill_err.contains("91-351"), "{}", skill_err);
crate::runtime::set_nerf_custom_cards(false);
wipe(5005);
}
#[test]
fn permission_gates() {
let _lock = crate::runtime::lock_test_data_path();
@@ -2274,4 +2815,106 @@ pub mod tests {
assert!(!viewer_can_resolve(100_010_001, 1));
assert!(viewer_can_resolve(100_010_001, 2));
}
// ---- defect-fix coverage -------------------------------------------------
// D14: card art lives at fixed per-card filenames that an in-place edit
// overwrites, so the md5 index can briefly point at bytes that are no longer
// its own. The client caches by md5 and never re-checks, so the route verifies
// the bytes before it serves them
#[test]
fn the_data_route_refuses_bytes_that_do_not_match_the_md5() {
let _lock = crate::runtime::lock_test_data_path();
wipe(4090);
let png = seeded_png(120, 60, 91);
let md5 = format!("{:x}", md5::compute(&png));
let id = database::next_card_id();
database::insert_card(id, 1001, 4090, &object!{
"master_card_id": id,
"rarity": 1,
"art": [{ "kind": "c", "variant": "00", "md5": md5.clone(), "size": png.len() }]
}, true, true).unwrap();
fs::create_dir_all(card_dir(id)).unwrap();
let file = format!("{}/c_00.png", card_dir(id));
fs::write(&file, &png).unwrap();
let call = || -> actix_web::http::StatusCode {
let req = actix_web::test::TestRequest::default()
.param("hash", md5.clone())
.param("file", format!("{}.png", md5))
.to_http_request();
actix_web::rt::System::new().block_on(async { data(req).await }).status()
};
assert_eq!(call(), actix_web::http::StatusCode::OK);
// The index still resolves, but the file no longer holds those bytes
fs::write(&file, seeded_png(120, 60, 92)).unwrap();
assert_eq!(call(), actix_web::http::StatusCode::NOT_FOUND);
fs::write(&file, &png).unwrap();
assert_eq!(call(), actix_web::http::StatusCode::OK);
wipe(4090);
}
// D13: JsonValue::to_string renders Null as the literal "null", so a stored
// line with no md5 used to aim the unlink at a file called null.ogg
#[test]
fn the_voice_gc_ignores_a_missing_md5() {
let _lock = crate::runtime::lock_test_data_path();
let character = 5_900_001;
let dir = format!("{}/voice", character_dir(character));
fs::create_dir_all(&dir).unwrap();
let decoy = format!("{}/null.ogg", dir);
fs::write(&decoy, b"not a voiceline").unwrap();
let real_md5 = "b1".repeat(16);
let real = voice_path(character, &real_md5);
fs::write(&real, b"a voiceline").unwrap();
let old = array![
{ "kind": "live_start", "index": 1, "md5": JsonValue::Null },
{ "kind": "live_start", "index": 2, "md5": real_md5.clone() }
];
gc_voice(character, &old, &array![]);
assert!(fs::read(&decoy).is_ok(), "the GC unlinked a file named after JSON null");
assert!(fs::read(&real).is_err(), "the dropped line's ogg was kept");
let _ = fs::remove_dir_all(character_dir(character));
}
// D15: cards and characters share one storage root, so they share one
// per-account byte quota
#[test]
fn uploads_are_bounded_by_a_per_account_byte_quota() {
let _lock = crate::runtime::lock_test_data_path();
wipe(4091);
assert_eq!(database::owner_bytes(4091, 0, 0), 0);
let id = database::next_card_id();
database::insert_card(id, 1001, 4091, &object!{
"master_card_id": id,
"rarity": 1,
"art": [{ "kind": "c", "variant": "00", "md5": "a1".repeat(16), "size": 1000 }]
}, true, true).unwrap();
let character = database::next_character_id();
database::insert_character(character, 4091, &object!{
"master_character_id": character,
"name": "Quota",
"art": [{ "kind": "icon", "md5": "a2".repeat(16), "size": 500 }],
"voice": [{ "kind": "live_start", "index": 1, "md5": "a3".repeat(16), "size": 250 }]
}).unwrap();
assert_eq!(database::owner_bytes(4091, 0, 0), 1750);
// An in-place edit replaces its own bytes rather than adding to them
assert_eq!(database::owner_bytes(4091, id, 0), 750);
assert_eq!(database::owner_bytes(4091, 0, character), 1000);
// Another account's uploads are not on this one's bill
assert_eq!(database::owner_bytes(4092, 0, 0), 0);
assert!(check_quota(4091, 1, 0, 0).is_ok());
assert!(check_quota(4091, MAX_BYTES_PER_USER, 0, 0).unwrap_err().contains("per-account limit"));
wipe(4091);
}
}
+181
View File
@@ -0,0 +1,181 @@
use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
use jzon::{object, JsonValue};
use crate::database::{custom_group, permissions};
use crate::router::{userdata, webui, rich_text};
use std::io::Cursor;
use futures_util::StreamExt;
pub const PROTOCOL_VERSION: u32 = 6;
pub fn web_routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/custom_group")
.route("/list", web::get().to(list))
.route("/create", web::post().to(create))
.route("/{id}/logo", web::post().to(upload_logo))
.route("/data/{hash}/{file}", web::get().to(logo_data)));
}
fn can_manage(req: &HttpRequest) -> bool {
webui::get_login_token(req)
.and_then(|token| userdata::webui_login_token(&token))
.and_then(|key| userdata::get_acc(&key)["user"]["id"].as_i64())
.is_some_and(|id| permissions::has(id, permissions::GROUP_MANAGE))
}
fn encode_logo(bytes: &[u8]) -> Result<Vec<u8>, String> {
let mut reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format().map_err(|_| "Invalid image")?;
if !matches!(reader.format(), Some(image::ImageFormat::Png | image::ImageFormat::Jpeg)) {
return Err("Logo must be PNG or JPEG".into());
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let image = reader.decode().map_err(|_| "Invalid or oversized image")?.thumbnail(512, 256);
let mut png = Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).map_err(|_| "Could not encode logo")?;
Ok(png.into_inner())
}
async fn upload_logo(req: HttpRequest, id: web::Path<i64>, mut payload: web::Payload) -> HttpResponse {
if !can_manage(&req) { return HttpResponse::Forbidden().finish(); }
if !custom_group::exists(*id) { return HttpResponse::NotFound().finish(); }
let mut bytes = Vec::new();
while let Some(chunk) = payload.next().await {
let Ok(chunk) = chunk else { return HttpResponse::BadRequest().finish(); };
if bytes.len() + chunk.len() > 4 * 1024 * 1024 { return HttpResponse::PayloadTooLarge().finish(); }
bytes.extend_from_slice(&chunk);
}
let png = match web::block(move || encode_logo(&bytes)).await {
Ok(Ok(png)) => png,
Ok(Err(message)) => return reply(object! { "result": "ERR", "message": message }),
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let hash = format!("{:x}", md5::compute(&png));
let dir = crate::runtime::get_data_path("custom_groups");
if store_logo(&dir, &hash, &png).is_err()
|| custom_group::set_logo(*id, &hash, png.len() as i64).is_err() {
return HttpResponse::InternalServerError().finish();
}
crate::database::custom_song::bump_revision();
crate::database::custom_card::bump_revision();
reply(object! { "result": "OK", "logo_md5": hash, "logo_size": png.len() })
}
fn store_logo(dir: &str, hash: &str, png: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let destination = format!("{dir}/{hash}.png");
if std::fs::read(&destination).is_ok_and(|bytes| bytes == png) { return Ok(()); }
let temporary = format!("{dir}/{}.tmp", uuid::Uuid::now_v7());
let result = std::fs::write(&temporary, png).and_then(|_| std::fs::rename(&temporary, destination));
if result.is_err() { let _ = std::fs::remove_file(temporary); }
result
}
async fn logo_data(path: web::Path<(String, String)>) -> HttpResponse {
let (hash, file) = path.into_inner();
if hash.len() != 32 || !hash.bytes().all(|b| b.is_ascii_hexdigit())
|| file != format!("{hash}.png") || !custom_group::has_logo(&hash) {
return HttpResponse::NotFound().finish();
}
match std::fs::read(crate::runtime::get_data_path(&format!("custom_groups/{hash}.png"))) {
Ok(bytes) if format!("{:x}", md5::compute(&bytes)) == hash => HttpResponse::Ok()
.insert_header(("Cache-Control", "public, max-age=31536000, immutable"))
.content_type("image/png").body(bytes),
_ => HttpResponse::NotFound().finish(),
}
}
fn reply(value: JsonValue) -> HttpResponse {
HttpResponse::Ok().insert_header(ContentType::json()).body(jzon::stringify(value))
}
async fn list() -> HttpResponse {
reply(object! { "result": "OK", "groups": custom_group::list() })
}
async fn create(req: HttpRequest, body: String) -> HttpResponse {
if !can_manage(&req) {
return reply(object! { "result": "ERR", "message": "You do not have permission to create groups" });
}
let Ok(data) = jzon::parse(&body) else {
return reply(object! { "result": "ERR", "message": "Invalid JSON" });
};
let name = data["name"].as_str().unwrap_or("").trim();
let name_en = data["name_en"].as_str().unwrap_or("").trim();
if name.is_empty() || name.chars().count() > 60 || name_en.chars().count() > 60 {
return reply(object! { "result": "ERR", "message": "Group name must be 1-60 characters (English name at most 60)" });
}
if let Err(message) = rich_text::reject_tags("Group name", name, &[]) {
return reply(object! { "result": "ERR", "message": message });
}
if let Err(message) = rich_text::reject_tags("English group name", name_en, &[]) {
return reply(object! { "result": "ERR", "message": message });
}
match custom_group::create(name, name_en) {
Ok(id) => reply(object! { "result": "OK", "id": id }),
Err(_) => reply(object! { "result": "ERR", "message": "A group with that name already exists, or it could not be stored" })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logo_preserves_aspect_and_transparency() {
let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(1024, 256, image::Rgba([1, 2, 3, 64])));
let mut input = Cursor::new(Vec::new());
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
let output = encode_logo(input.get_ref()).unwrap();
let decoded = image::load_from_memory(&output).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), (512, 128));
assert_eq!(decoded.get_pixel(0, 0).0[3], 64);
assert!(encode_logo(b"not an image").is_err());
}
#[test]
fn rejects_oversized_dimensions() {
let source = image::DynamicImage::new_rgba8(4097, 1);
let mut input = Cursor::new(Vec::new());
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
assert!(encode_logo(input.get_ref()).is_err());
}
#[actix_web::test]
async fn logo_upload_requires_group_permission() {
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
let request = actix_web::test::TestRequest::post().uri("/custom_group/10000/logo").set_payload("invalid").to_request();
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::FORBIDDEN);
}
#[actix_web::test]
async fn logo_catalog_and_content_addressed_download() {
let _lock = crate::runtime::lock_test_data_path();
let id = custom_group::create(&format!("Logo test {}", uuid::Uuid::now_v7()), "").unwrap();
let mut input = Cursor::new(Vec::new());
image::DynamicImage::new_rgba8(32, 16).write_to(&mut input, image::ImageFormat::Png).unwrap();
let png = encode_logo(input.get_ref()).unwrap();
let hash = format!("{:x}", md5::compute(&png));
let dir = crate::runtime::get_data_path("custom_groups");
store_logo(&dir, &hash, &png).unwrap();
store_logo(&dir, &hash, &png).unwrap();
custom_group::set_logo(id, &hash, png.len() as i64).unwrap();
let catalog = custom_group::list();
let row = catalog.members().find(|g| g["id"].as_i64() == Some(id)).unwrap();
assert_eq!(row["logo_md5"].as_str(), Some(hash.as_str()));
assert_eq!(row["logo_size"].as_usize(), Some(png.len()));
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
let response = actix_web::test::call_service(&app, request).await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
assert_eq!(actix_web::test::read_body(response).await.as_ref(), png);
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/wrong.png")).to_request();
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
std::fs::write(format!("{dir}/{hash}.png"), b"corrupted").unwrap();
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
}
}
+846 -214
View File
File diff suppressed because it is too large Load Diff
+65 -7
View File
@@ -7,7 +7,7 @@ use symphonia::core::formats::probe::Hint;
use symphonia::core::io::MediaSourceStream;
use vorbis_rs::{VorbisBitrateManagementStrategy, VorbisEncoderBuilder};
use super::{DEFAULT_PREVIEW_LENGTH_SEC, PREVIEW_FADE_SEC};
use super::{DEFAULT_PREVIEW_LENGTH_SEC, MAX_AUDIO_SECONDS, PREVIEW_FADE_SEC};
// The whole audio pipeline runs in-process: symphonia (pure Rust) decodes and
// validates uploads, vorbis_rs (libvorbis compiled into the binary - a library
@@ -53,7 +53,12 @@ fn is_ogg_vorbis(bytes: &[u8]) -> bool {
bytes.starts_with(b"OggS") && bytes.len() > 64 && bytes[..64].windows(7).any(|w| w == b"\x01vorbis")
}
fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
// `max_duration_sec` is enforced INSIDE the packet loop, not after it: the decode
// accumulates full planar f32 PCM, so checking the duration afterwards means the
// allocation has already happened (an hour of 44.1kHz stereo is ~1.4GB of Vec).
// Bailing at the first packet past the limit keeps the peak proportional to the
// limit instead of to the upload
fn decode(bytes: &[u8], max_duration_sec: f64) -> Result<DecodedAudio, String> {
let stream = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.to_vec())), Default::default());
let mut format = symphonia::default::get_probe()
.probe(&Hint::new(), stream, Default::default(), Default::default())
@@ -96,6 +101,9 @@ fn decode(bytes: &[u8]) -> Result<DecodedAudio, String> {
for (i, samples) in channels.iter_mut().enumerate() {
samples.extend(interleaved.iter().skip(i).step_by(count));
}
if sample_rate > 0 && channels[0].len() as f64 / sample_rate as f64 > max_duration_sec {
return Err(format!("Audio is over the {:.0} second maximum", max_duration_sec));
}
}
if channels.is_empty() || channels[0].is_empty() || sample_rate == 0 {
@@ -141,14 +149,13 @@ fn cue(bytes: Vec<u8>, duration_sec: f64) -> Cue {
// reads, keep it as-is when it's already ogg-vorbis, otherwise transcode. No
// cuts, fades, loop points or preview split - mono or stereo as-sourced
pub fn process_one_shot(bytes: &[u8], max_duration_sec: f64) -> Result<Cue, String> {
let audio = decode(bytes)?;
// The length limit belongs to the decoder, which stops at the first packet
// past it rather than accumulating the whole clip and rejecting it afterwards
let audio = decode(bytes, max_duration_sec)?;
let duration = audio.duration();
if duration < 0.2 {
return Err(String::from("Audio clip is shorter than 0.2 seconds"));
}
if duration > max_duration_sec {
return Err(format!("Audio clip is {:.1} seconds long - the maximum is {:.0} seconds", duration, max_duration_sec));
}
if is_ogg_vorbis(bytes) {
return Ok(cue(bytes.to_vec(), duration));
}
@@ -160,7 +167,7 @@ pub fn process_one_shot(bytes: &[u8], max_duration_sec: f64) -> Result<Cue, Stri
// fades. Both are stored content-addressed by the md5 of the final ogg bytes -
// the client validates md5(file) against the value served in the catalog
pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec: Option<f64>) -> Result<(Cue, Cue), String> {
let audio = decode(bytes)?;
let audio = decode(bytes, MAX_AUDIO_SECONDS)?;
let duration = audio.duration();
if duration <= 1.0 {
return Err(String::from("Audio track is too short"));
@@ -200,3 +207,54 @@ pub fn process(bytes: &[u8], preview_start_sec: Option<f64>, preview_length_sec:
Ok((play, select))
}
#[cfg(test)]
mod tests {
use super::*;
// 44.1kHz 16-bit mono, `seconds` long
fn wav(seconds: f64) -> Vec<u8> {
let sample_rate: u32 = 44100;
let frames = (seconds * sample_rate as f64) as u32;
let data_len = frames * 2;
let mut rv = Vec::new();
rv.extend(b"RIFF");
rv.extend((36 + data_len).to_le_bytes());
rv.extend(b"WAVEfmt ");
rv.extend(16u32.to_le_bytes());
rv.extend(1u16.to_le_bytes());
rv.extend(1u16.to_le_bytes());
rv.extend(sample_rate.to_le_bytes());
rv.extend((sample_rate * 2).to_le_bytes());
rv.extend(2u16.to_le_bytes());
rv.extend(16u16.to_le_bytes());
rv.extend(b"data");
rv.extend(data_len.to_le_bytes());
for i in 0..frames {
let sample = ((i as f64 * 440.0 * 2.0 * std::f64::consts::PI / sample_rate as f64).sin() * 8000.0) as i16;
rv.extend(sample.to_le_bytes());
}
rv
}
// The length limit is enforced from INSIDE the packet loop: the decode
// accumulates full planar f32 PCM, so a post-decode check means the
// allocation already happened (an hour of stereo is ~1.4GB of Vec)
#[test]
fn the_decoder_stops_at_the_duration_cap() {
let three_seconds = wav(3.0);
let audio = decode(&three_seconds, 5.0).unwrap();
assert!((audio.duration() - 3.0).abs() < 0.01);
let Err(err) = decode(&three_seconds, 1.0) else {
panic!("a three-second track decoded under a one-second cap");
};
assert!(err.contains("1 second maximum"), "{}", err);
// The same cap is what refuses an over-long voiceline
let Err(err) = process_one_shot(&three_seconds, 1.0) else {
panic!("an over-long one-shot was accepted");
};
assert!(err.contains("1 second maximum"), "{}", err);
assert!(process_one_shot(&three_seconds, 5.0).is_ok());
}
}
+173 -198
View File
@@ -1,4 +1,5 @@
use jzon::{object, JsonValue};
use std::collections::HashMap;
// Transcodes a SIF1/NPPS4 beatmap (array of {timing_sec, effect, effect_value, position})
// into the SIF2 chart JSON the client deserializes into NoteData.
@@ -8,17 +9,17 @@ use jzon::{object, JsonValue};
// note_bomb_3 5, note_bomb_5 6, note_bomb_9 7, note_slide 11, note_slide_event 12,
// note_slide_hold 13, with isHold(e) = e == 3 and isSlide(e) = e >= 11.
//
// SIF2 side: `type` only distinguishes an ordinary note (1) from a star/bomb note (3).
// LiveTimeController.ToMarkerType accepts 1..3 and maps anything else to None; type 2 exists in
// the enum but appears in ZERO of the 2146 shipped charts, so nothing emits it here.
// SIF2 side: type 1 is a tap, 2 is a flick, and 3 is a skill/star note.
// LiveTimeController.ToMarkerType accepts 1..3; stock charts happen not to
// use type 2, but imported Bandori charts do.
//
// A SLIDE is structural, not a type. MarkerData derives it from the parent/child chain:
// IsSliderMarker chained and the child is on a DIFFERENT line -> a slide segment
// IsSliderLongMarker chained, child SAME line, parent different -> a slide ending in a hold
// IsDistanceMarker has both a parent and a child -> a middle segment
// So a hold is a chain that stays in its lane and a slide is a chain that moves across lanes;
// both are type 1. This is also how the client counts combo (NoteData.CalcMaxCombo: a note whose
// child shares its line does not count, its tail does).
// both are type 1. Combo follows LiveStatusSystem.PushInputResult, not the editor-only
// NoteData.CalcMaxCombo helper, which undercounts repeated same-lane checkpoints.
//
// Mapping rules:
// - line = position - 1 (both are right-to-left)
@@ -32,16 +33,19 @@ use jzon::{object, JsonValue};
// varies the blast width per effect; SIF2 has one bomb with one damage value, so the
// four collapse into type 3 and only the radius is lost. Previously only bomb_1 mapped
// here and the three wider ones arrived as ordinary taps.
// - effect 11/12/13 (slide) -> CHAINED across lanes, all type 1. Slides sharing a notes_level
// form one run: sorted by time and linked parent -> child, so each link crosses lanes and
// the client sees a slider. A run ends on effect 13 (slide hold), whose synthesized
// - effect 11/12/13/14 (slide) -> CHAINED across lanes, normally type 1. Slides sharing a notes_level
// form one run: sorted by time and linked parent -> child. Cross-lane links
// are sliders; same-lane links are hold segments. A run ends on effect 13 (slide hold), whose synthesized
// same-line tail then makes it IsSliderLongMarker — the slide settling into a hold the
// player releases. Verified against a real upload: every notes_level shared by more than
// one note held exactly the slide-effect notes, each a monotonic sweep like
// pos 9->8->7->6 with effects 11,11,11,13.
// A lone slide with no chain partner stays a plain tap: a slider needs a cross-lane child.
// - effect 0 (random) and anything else unknown -> plain type 1. Every effect the game
// actually defines is covered above, so this is only a floor for hand-authored charts.
// A lone slide with no chain partner stays a plain note. Same-lane links
// become hold segments; effect 14 keeps the final point as a flick.
// - effects 8/14 are custom-song extensions for a flick and a slide-end flick (type 2).
// Effect 9 is a skill note (type 3); effect 10 is a hold with a flick tail.
// The stock SIF1 vocabulary does not use 8/9/10/14.
// - effect 0 (random) and anything else unknown -> plain type 1.
// - notes_attribute is dropped (SIF2 has no per-note attribute). notes_level is consumed as
// the chain id above and not emitted.
// - ids are sequential from 1 in time order. num is the spawn group: the dummy
@@ -61,8 +65,8 @@ use jzon::{object, JsonValue};
// ForceGroupId against the other chunk's GroupId and links the lane-closest pair).
// - notes[0] is ALWAYS the dummy header (id 0, num 100, type 0) - the client
// deserializes it verbatim.
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
// line (the game counts a same-lane hold as one combo for the chain)
// - max_combo_count excludes a same-lane hold's root and a cross-lane slide settling
// into a hold. Further same-lane checkpoints do count (see max_combo_count below).
// Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this.
// Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to
@@ -87,6 +91,14 @@ fn simultaneous(a: f64, b: f64) -> bool {
const MISS_OFFSET_SEC: f64 = 0.15;
const MISS_OFFSET_SLIDER_SEC: f64 = 0.34;
// The most notes one difficulty may carry. Every transcode step is linear in this
// and the chart JSON is bounded only in bytes by the upload caps, so an explicit
// ceiling is what keeps a 64MB chart from turning into tens of millions of
// JsonValue allocations on a worker. The hardest shipped SIF2 chart is under 1500
// notes and the whole 2146-chart official set peaks well below that, so this is
// more than an order of magnitude of headroom
pub const MAX_NOTES: usize = 20_000;
// MarkerData.IsSliderMarker: a chained note with a cross-lane parent or child.
fn is_slider(data: &JsonValue, line_of: &dyn Fn(i64) -> Option<i64>) -> bool {
let parent_id = data["parent_id"].as_i64().unwrap_or(0);
@@ -106,10 +118,12 @@ fn is_slider(data: &JsonValue, line_of: &dyn Fn(i64) -> Option<i64>) -> bool {
// m_MusicDuration is LiveMst._endWait + the music length, and _endWait is 0 in every one of the
// 637 official live rows and in ours), so this is what has to fit inside the audio.
pub fn end_time(chart: &JsonValue) -> f64 {
let lines: Vec<(i64, i64)> = chart["notes"].members().skip(1)
// Indexed, not scanned: this runs once per note over a chart whose note count
// is only bounded by MAX_NOTES, and the linear lookup made it quadratic
let lines: HashMap<i64, i64> = chart["notes"].members().skip(1)
.map(|n| (n["id"].as_i64().unwrap_or(0), n["line"].as_i64().unwrap_or(0)))
.collect();
let line_of = |id: i64| lines.iter().find(|(i, _)| *i == id).map(|(_, line)| *line);
let line_of = |id: i64| lines.get(&id).copied();
let mut end: f64 = 0.0;
for data in chart["notes"].members().skip(1) {
@@ -129,6 +143,42 @@ pub fn first_note_time(chart: &JsonValue) -> Option<f64> {
}))
}
// LiveStatusSystem.PushInputResult skips combo for a non-slider long root or
// IsSliderLongMarker. LiveInputResultControl maps those results to their child.
// All other points count, including repeated checkpoints in a stationary slide.
// Validate the links before repairing stored data; never guess a broken chart's count.
pub fn max_combo_count(chart: &JsonValue) -> Option<i64> {
let notes = &chart["notes"];
if !notes.is_array() || notes.len() < 2 || notes[0]["id"] != 0 {
return None;
}
let mut by_id = HashMap::new();
for note in notes.members().skip(1) {
let id = note["id"].as_i64()?;
let line = note["line"].as_i64()?;
if id <= 0 || !(0..9).contains(&line) || by_id.insert(id, note).is_some() {
return None;
}
}
let mut count = 0;
for note in notes.members().skip(1) {
let id = note["id"].as_i64()?;
let parent_id = note["parent_id"].as_i64()?;
let child_id = note["child_id"].as_i64()?;
let parent = if parent_id == 0 { None } else { Some(*by_id.get(&parent_id)?) };
let child = if child_id == 0 { None } else { Some(*by_id.get(&child_id)?) };
if parent_id == id || child_id == id
|| parent.is_some_and(|p| p["child_id"] != id)
|| child.is_some_and(|c| c["parent_id"] != id) {
return None;
}
let hold_start = child.is_some_and(|c| c["line"] == note["line"])
&& parent.is_none_or(|p| p["line"] != note["line"]);
if !hold_start { count += 1; }
}
Some(count)
}
struct WorkNote {
time: f64,
line: i64,
@@ -142,7 +192,7 @@ struct WorkNote {
// LiveModel.NoteEffect.isHold, widened to note_slide_hold: both carry a duration in
// effect_value (notes.lua isTimeOver adds effect_value for note_hold and note_slide_hold alike).
fn is_hold(effect: i64) -> bool {
effect == 3 || effect == 13
effect == 3 || effect == 10 || effect == 13
}
// LiveModel.NoteEffect.isSlide
@@ -171,6 +221,12 @@ fn parse_sif_note(data: &JsonValue, index: usize) -> Result<(f64, i64, f64, i64,
if !(1..=9).contains(&position) {
return Err(format!("Note {}: position {} is outside 1-9", index, position));
}
// NaN and the infinities pass every comparison below and then reach the
// time-order sort, whose partial_cmp().unwrap() panics on an incomparable
// pair - a worker panic authored by the uploaded file
if !timing.is_finite() || !effect_value.is_finite() {
return Err(format!("Note {}: timing_sec/effect_value must be finite numbers", index));
}
if timing < 0.0 {
return Err(format!("Note {}: negative timing_sec {}", index, timing));
}
@@ -186,24 +242,38 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
if !beatmap.is_array() || beatmap.is_empty() {
return Err(String::from("Chart is not a JSON array of notes"));
}
if beatmap.len() > MAX_NOTES {
return Err(format!("Chart has {} notes - the maximum is {}", beatmap.len(), MAX_NOTES));
}
let mut work: Vec<WorkNote> = Vec::new();
// Slide chain id -> the work indices in that chain, in input order
let mut chains: Vec<(i64, Vec<usize>)> = Vec::new();
// (timing bits, position) -> effect, for the duplicate check below. Indexed
// rather than rescanned: the old scan-all-preceding-notes loop was quadratic
// over an input whose size the upload caps only bound in bytes. The key uses
// the raw f64 bits, which is exactly the equality the scan tested (both
// infinities and NaN are already rejected in parse_sif_note, so bit equality
// and value equality agree here)
let mut seen: HashMap<(u64, i64), i64> = HashMap::new();
for (i, data) in beatmap.members().enumerate() {
let (timing, effect, effect_value, position, group) = parse_sif_note(data, i)?;
for other in beatmap.members().take(i) {
if other["timing_sec"].as_f64() == Some(timing) && other["position"].as_i64() == Some(position) && other["effect"].as_i64() != Some(effect) {
match seen.insert((timing.to_bits(), position), effect) {
Some(other) if other != effect => {
return Err(format!("Note {}: duplicate timing {} on position {} with a different effect", i, timing, position));
}
},
_ => {}
}
let head = work.len();
work.push(WorkNote {
time: timing,
line: position - 1,
kind: if is_bomb(effect) { 3 } else { 1 },
// Effects 8/14 are the custom-song interchange's flick and
// slide-end flick; 9 carries a Bandori skill note.
kind: if effect == 8 || effect == 14 { 2 }
else if effect == 9 || is_bomb(effect) { 3 } else { 1 },
parent: None,
child: None
});
@@ -221,7 +291,7 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
work.push(WorkNote {
time: timing + effect_value,
line: position - 1,
kind: 1,
kind: if effect == 10 { 2 } else { 1 },
parent: Some(head),
child: None
});
@@ -229,8 +299,8 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
}
}
// Link each slide chain in time order. Consecutive members sit on different lines, which is
// exactly what makes SIF2 treat the run as a slider rather than a hold. A member that already
// Link each slide chain in time order. A cross-lane link becomes a slider
// and a same-lane link becomes a hold segment. A member that already
// has a child is a slide-hold, i.e. the end of the run, so the chain stops there — its tail
// stays its child and the cross-lane parent link makes it IsSliderLongMarker.
for (_, members) in chains.iter() {
@@ -245,10 +315,6 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
if work[a].child.is_some() || work[b].parent.is_some() {
break;
}
if work[a].line == work[b].line {
// Same lane would read as a hold, not a slide; skip the link rather than lie
continue;
}
work[a].child = Some(b);
work[b].parent = Some(a);
}
@@ -294,17 +360,9 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
"parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0,
"force_sync_group_id": 0
}];
let mut max_combo_count = 0;
for index in order.iter() {
let note = &work[*index];
// NoteData.CalcMaxCombo: a note whose child is on the SAME line (a hold) does not count,
// its tail does. A cross-lane child (a slide segment) counts normally.
match note.child {
Some(child) if work[child].line == note.line => {},
_ => max_combo_count += 1
}
notes.push(object!{
"id": ids[*index],
"num": nums[*index],
@@ -319,120 +377,17 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
}).unwrap();
}
Ok((object!{
let mut chart = object!{
"max_lane": 9,
"sound_name": "",
"max_combo_count": max_combo_count,
"max_combo_count": 0,
"notes": notes
}, max_combo_count))
};
let combo = max_combo_count(&chart).ok_or("Invalid transcoded note links")?;
chart["max_combo_count"] = combo.into();
Ok((chart, combo))
}
// Regroups a STORED transcoded chart whose spawn groups predate the pairing rule above: the
// old transcoder gave every note of an equal-time cluster one shared num (force_sync_group_id
// always 0), and the client renders no head markers for a group of 3+ (see the header
// comment). This rebuilds num / force_sync_group_id in place with the same clustering the
// transcoder now uses — equal final time, lane-sorted, chunks of two, chained
// force_sync_group_id — and re-points child_num at each child's renumbered spawn group.
// Everything else (ids, times, lines, types, parent/child links, max_combo_count — combo
// counting never depended on grouping) is untouched, so on a chart the current transcoder
// produced this reproduces the stored bytes exactly.
//
// Returns false (chart untouched) unless some num is shared by MORE than two notes. That
// makes it a safe no-op on current uploads AND on official-style encodings (whose num values
// differ from ours — e.g. gaps of 3 — but whose groups never exceed two).
pub fn regroup(chart: &mut JsonValue) -> bool {
// (id, time, line) per real note; the dummy header at [0] stays untouched
let notes: Vec<(i64, f64, i64)> = chart["notes"].members().skip(1).map(|n| (
n["id"].as_i64().unwrap_or(0),
n["time"].as_f64().unwrap_or(0.0),
n["line"].as_i64().unwrap_or(0)
)).collect();
// Only a pre-pairing chart (some num shared 3+ ways) is rewritten
let mut group_sizes: Vec<(i64, i64)> = Vec::new();
for data in chart["notes"].members().skip(1) {
let num = data["num"].as_i64().unwrap_or(0);
match group_sizes.iter_mut().find(|(n, _)| *n == num) {
Some((_, count)) => *count += 1,
None => group_sizes.push((num, 1))
}
}
if group_sizes.iter().all(|(_, count)| *count <= 2) {
return false;
}
// Time order; ids break ties (transcode issues them in time order, so this reproduces
// the emission order the grouping pass originally saw)
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by(|a, b| notes[*a].1.total_cmp(&notes[*b].1).then(notes[*a].0.cmp(&notes[*b].0)));
// id -> (new num, new force_sync_group_id)
let mut assigned: Vec<(i64, i64, i64)> = Vec::with_capacity(notes.len());
let mut num = 100;
let mut start = 0;
while start < order.len() {
let mut end = start + 1;
while end < order.len() && simultaneous(notes[order[end]].1, notes[order[start]].1) {
end += 1;
}
let mut cluster: Vec<usize> = order[start..end].to_vec();
cluster.sort_by_key(|index| notes[*index].2);
let mut prev_num = 0;
for chunk in cluster.chunks(2) {
num += 1;
for index in chunk {
assigned.push((notes[*index].0, num, prev_num));
}
prev_num = num;
}
start = end;
}
let lookup = |id: i64| assigned.iter().find(|(i, _, _)| *i == id).map(|(_, n, f)| (*n, *f));
for data in chart["notes"].members_mut().skip(1) {
let Some((new_num, force)) = lookup(data["id"].as_i64().unwrap_or(0)) else { continue; };
data["num"] = new_num.into();
data["force_sync_group_id"] = force.into();
let child = data["child_id"].as_i64().unwrap_or(0);
if child != 0 {
// child_num names the child's spawn group and must follow its new num
data["child_num"] = lookup(child).map(|(n, _)| n).unwrap_or(0).into();
}
}
true
}
// Test helper: fabricates what pre-pairing servers stored, by squashing a current chart back
// to the OLD encoding — one shared num per equal-time cluster, force_sync_group_id 0, and
// child_num following. Lives outside the tests module so the migration tests in
// router/custom_song.rs can build realistic pre-fix fixtures from transcode output.
#[cfg(test)]
pub fn squash_to_pre_fix(chart: &mut JsonValue) {
let notes: Vec<(i64, f64)> = chart["notes"].members().skip(1)
.map(|n| (n["id"].as_i64().unwrap(), n["time"].as_f64().unwrap()))
.collect();
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by(|a, b| notes[*a].1.total_cmp(&notes[*b].1).then(notes[*a].0.cmp(&notes[*b].0)));
let mut nums: Vec<(i64, i64)> = Vec::new();
let mut num = 100;
let mut last_time = f64::NEG_INFINITY;
for index in order {
if notes[index].1 != last_time {
num += 1;
last_time = notes[index].1;
}
nums.push((notes[index].0, num));
}
let lookup = |id: i64| nums.iter().find(|(i, _)| *i == id).map(|(_, n)| *n).unwrap_or(0);
for data in chart["notes"].members_mut().skip(1) {
data["num"] = lookup(data["id"].as_i64().unwrap()).into();
data["force_sync_group_id"] = 0.into();
let child = data["child_id"].as_i64().unwrap_or(0);
if child != 0 {
data["child_num"] = lookup(child).into();
}
}
}
#[cfg(test)]
mod tests {
@@ -802,6 +757,56 @@ mod tests {
assert_eq!(combo, 3);
}
#[test]
fn repeated_same_lane_checkpoints_count_like_the_client() {
for final_effect in [11, 14] {
let mut beatmap = jzon::array![];
for i in 0..13 {
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, 5,
if i == 12 { final_effect } else { 11 }, 0.0, 500)).unwrap();
}
let (chart, combo) = transcode(&beatmap).unwrap();
// The head shares the first checkpoint's result; all twelve
// non-root points still have their own runtime judgment.
assert_eq!(combo, 12);
assert_eq!(chart["max_combo_count"], 12);
assert_eq!(chart["notes"].len(), 14);
}
}
#[test]
fn combo_count_preserves_hold_and_cross_lane_rules() {
for (positions, expected) in [
(vec![5, 5], 1),
(vec![5, 5, 5], 2),
(vec![5, 5, 6], 2),
(vec![4, 5, 5], 2),
(vec![4, 5, 5, 5], 3),
(vec![4, 5, 6], 3),
] {
let mut beatmap = jzon::array![];
for (i, position) in positions.iter().enumerate() {
beatmap.push(sif_slide(1.0 + i as f64 * 0.1, *position, 11, 0.0, 500)).unwrap();
}
let (_, combo) = transcode(&beatmap).unwrap();
assert_eq!(combo, expected, "chain {:?}", positions);
}
}
#[test]
fn combo_repair_rejects_invalid_links() {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, 3, 1.0)]).unwrap();
for (field, value) in [("id", 2), ("parent_id", 999), ("child_id", 999), ("child_id", 1)] {
let mut broken = chart.clone();
broken["notes"][1][field] = value.into();
assert_eq!(max_combo_count(&broken), None, "invalid {}={}", field, value);
}
let mut broken = chart;
broken["notes"][2]["parent_id"] = 0.into();
assert_eq!(max_combo_count(&broken), None);
assert_eq!(max_combo_count(&object!{}), None);
}
#[test]
fn slide_hold_needs_a_duration() {
// The effect 3 duration check has to cover note_slide_hold too
@@ -839,13 +844,32 @@ mod tests {
#[test]
fn undefined_effects_fall_back_to_taps() {
// Not part of NoteEffect; a hand-authored chart must still transcode to something valid
for effect in [0, 8, 9, 10] {
for effect in [0, 15] {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
assert_eq!(chart["notes"][1]["type"], 1, "effect {}", effect);
}
}
#[test]
fn bandori_flick_skill_and_slide_flick() {
for (effect, kind) in [(8, 2), (9, 3), (14, 2)] {
let (chart, _) = transcode(&jzon::array![sif_note(1.0, 5, effect, 0.0)]).unwrap();
assert_eq!(chart["notes"][1]["type"], kind, "effect {}", effect);
}
let (chart, combo) = transcode(&jzon::array![
sif_slide(1.0, 7, 11, 0.0, 2),
sif_slide(1.3, 5, 14, 0.0, 2)
]).unwrap();
assert_eq!(combo, 2);
assert_eq!(chart["notes"][2]["type"], 2);
assert_eq!(chart["notes"][1]["child_id"], chart["notes"][2]["id"].clone());
let (hold, combo) = transcode(&jzon::array![sif_note(1.0, 5, 10, 0.5)]).unwrap();
assert_eq!(combo, 1);
assert_eq!(hold["notes"][1]["type"], 1);
assert_eq!(hold["notes"][2]["type"], 2);
}
// Lifted verbatim from a chart uploaded to the live server (custom song 10008, "Edelied",
// exported via /custom_song/download/10008) — a swipe run of note_slide into note_slide_hold,
// which is the shape that used to arrive as undifferentiated taps. Note the editor writes a
@@ -880,11 +904,9 @@ mod tests {
}
}
// The 10011 field shape: 4 parallel holds into a full 9-lane wall into a triple. Squashing
// transcode output reproduces the old encoding exactly (one num per cluster, no force
// links); regroup must restore the current encoding BYTE-IDENTICALLY, child_num included.
// Parallel holds and a nine-lane wall must retain valid spawn groups.
#[test]
fn regroup_restores_pre_fix_wall_and_parallel_holds() {
fn transcode_wall_and_parallel_holds() {
let beatmap = jzon::array![
sif_note(1.0, 2, 3, 1.0), sif_note(1.0, 4, 3, 1.0),
sif_note(1.0, 6, 3, 1.0), sif_note(1.0, 8, 3, 1.0),
@@ -893,23 +915,7 @@ mod tests {
sif_note(2.75, 7, 1, 0.0), sif_note(2.75, 8, 1, 0.0), sif_note(2.75, 9, 1, 0.0),
sif_note(3.625, 3, 1, 0.0), sif_note(3.625, 5, 1, 0.0), sif_note(3.625, 7, 1, 0.0)
];
let (expected, _) = transcode(&beatmap).unwrap();
let mut chart = expected.clone();
squash_to_pre_fix(&mut chart);
// Sanity: the squash really is the old encoding — whole clusters share one num
assert_eq!(chart["notes"][1]["num"], 101);
assert_eq!(chart["notes"][4]["num"], 101); // all 4 hold heads
assert_eq!(chart["notes"][9]["num"], 103);
assert_eq!(chart["notes"][17]["num"], 103); // all 9 wall notes
assert_eq!(chart["notes"][1]["child_num"], 102, "squashed child_num must follow");
for data in chart["notes"].members() {
assert_eq!(data["force_sync_group_id"], 0);
}
assert!(regroup(&mut chart), "a squashed chart must be rewritten");
assert_eq!(jzon::stringify(chart.clone()), jzon::stringify(expected.clone()),
"regroup must reproduce the current transcoder's output exactly");
let (chart, _) = transcode(&beatmap).unwrap();
// Spell the wall out: adjacent-lane pairs, each later chunk force-synced to the
// previous one (heads 101/102, tails 103/104, wall 105..109, triple 110/111)
@@ -936,37 +942,6 @@ mod tests {
}
}
#[test]
fn regroup_is_a_no_op_on_current_encoding() {
let beatmap = jzon::array![
sif_note(1.0, 2, 1, 0.0), sif_note(1.0, 5, 1, 0.0), sif_note(1.0, 8, 1, 0.0),
sif_note(2.0, 4, 3, 1.5)
];
let (chart, _) = transcode(&beatmap).unwrap();
let before = jzon::stringify(chart.clone());
let mut chart = chart;
assert!(!regroup(&mut chart));
assert_eq!(jzon::stringify(chart), before);
}
// Official-shaped encodings (1132_5_Sn t=9.781: num gaps of 3, force_sync naming the other
// pair) have groups of at most two and must never be "normalized" to our num sequence
#[test]
fn regroup_is_a_no_op_on_official_shaped_charts() {
let mut chart = object!{
"max_lane": 9, "sound_name": "", "max_combo_count": 4,
"notes": [
{"id": 0, "num": 100, "line": 0, "time": 0.0, "type": 0, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
{"id": 45, "num": 145, "line": 0, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
{"id": 46, "num": 145, "line": 1, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 0},
{"id": 47, "num": 148, "line": 7, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145},
{"id": 48, "num": 148, "line": 8, "time": 9.781, "type": 1, "parent_id": 0, "child_id": 0, "child_num": 0, "child_line": 0, "force_sync_group_id": 145}
]
};
let before = jzon::stringify(chart.clone());
assert!(!regroup(&mut chart));
assert_eq!(jzon::stringify(chart), before);
}
#[test]
fn rejects_bad_charts() {
-121
View File
@@ -1,121 +0,0 @@
use std::fs;
use super::{chart, database, song_path, asset_meta, LEVEL_COUNT};
use crate::runtime::get_data_path;
// One-time, idempotent startup migration for charts transcoded before the spawn-group pairing
// rule (chart.rs header): the old transcoder gave every note of an equal-time cluster ONE
// shared num, and the client creates no head markers for a group of 3+ notes
// (LiveMarkerControl.CreateMarkerUI plain-returns on count > 2), so 3+ simultaneous notes
// were judged but never rendered. Stored transcoded charts are derived data with everything
// the regroup needs (time + line per note), so they are rewritten in place — no original
// upload required, which also covers songs from before export support.
//
// The same pass also corrects the stored catalog values that were fabricated rather than
// derived from official masterdata (see below): the looping PLAY cue, the per-song score-rank
// thresholds and the combo-mission targets.
//
// For each song directory with a catalog row, every chart with an over-shared num is
// regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog
// blob updated — the changed md5 re-keys the client's content-addressed cache, so clients
// re-download the fixed chart on their next catalog sync. The revision is bumped ONCE if
// anything changed. Charts the current transcoder produced (and official-shaped encodings)
// are left byte-identical, so re-running every boot is free.
pub fn run() {
if super::disabled() {
return;
}
let Ok(entries) = fs::read_dir(get_data_path("custom_songs")) else {
// No custom_songs directory yet - nothing was ever uploaded
return;
};
let mut music_ids: Vec<i64> = entries.flatten()
.filter_map(|entry| entry.file_name().to_string_lossy().parse::<i64>().ok())
.collect();
music_ids.sort();
let mut songs_changed = 0;
let mut charts_changed = 0;
for music_id in music_ids {
// A song directory without a catalog row is never served; leave it alone
let Some(mut song) = database::get_song(music_id) else { continue; };
let mut changed = false;
// Catalogs written before cue_json took an is_loop argument marked BOTH cues as loop
// cues. A looping PLAY cue never reports playback-end to the client, and the live's end
// trigger (LiveTimeController: isMusicEnded -> InLiveDelay -> EndWait) hangs off exactly
// that, so those lives never finish. Only the metadata changes - the ogg bytes and their
// md5 are untouched, so no re-download is needed, just a catalog resync.
if song["sound"]["play"]["is_loop"] == true {
song["sound"]["play"]["is_loop"] = false.into();
song["sound"]["play"]["loop_end_sec"] = 0.0.into();
changed = true;
println!("Custom song {}: play cue un-looped (pre-fix catalog)", music_id);
}
// Score-rank thresholds and combo-mission targets used to be invented per song. Both are
// objective masterdata: the official live rows all carry ONE score tuple, and every
// live_mission_combo row is round(hardest full combo * 0.2/0.4/0.6/0.8) (see
// default_scores / mission_combo). Recompute them the way an edit would, so songs
// uploaded before the fix stop handing out rank S for free and stop asking for a literal
// full combo on the fourth combo mission.
let (score, multi_score) = super::default_scores();
if song["score"] != score || song["multi_score"] != multi_score {
song["score"] = score;
song["multi_score"] = multi_score;
changed = true;
println!("Custom song {}: score rank thresholds reset to the official values", music_id);
}
// Same "hardest difficulty" rule as upload/edit: the last level entry, which both write
// in ascending level order
if let Some(hardest) = song["levels"].members().last().and_then(|l| l["full_combo"].as_i64()) {
let missions = super::mission_combo(hardest);
if song["mission_combo"] != missions {
song["mission_combo"] = missions;
changed = true;
println!("Custom song {}: combo missions rescaled to the official 20/40/60/80%", music_id);
}
}
for level in 1..=LEVEL_COUNT {
let path = song_path(music_id, &format!("chart_{}.json", level));
let Ok(bytes) = fs::read(&path) else { continue; };
let Ok(mut chart_data) = jzon::parse(&String::from_utf8_lossy(&bytes)) else {
println!("Custom song {} chart {}: not valid JSON, migration skipped", music_id, level);
continue;
};
if !chart::regroup(&mut chart_data) {
continue;
}
let new_bytes = jzon::stringify(chart_data);
if let Err(e) = fs::write(&path, &new_bytes) {
println!("Custom song {} chart {}: rewrite failed ({}), migration skipped", music_id, level, e);
continue;
}
// The catalog md5/size must follow the served bytes or the client's
// download-and-verify loop would never accept the asset
let (md5, size) = asset_meta(new_bytes.as_bytes());
for entry in song["levels"].members_mut() {
if entry["level"] == level {
entry["md5"] = md5.clone().into();
entry["size"] = size.into();
}
}
changed = true;
charts_changed += 1;
println!("Custom song {}: regrouped chart level {} (pre-pairing spawn groups)", music_id, level);
}
if changed {
database::update_song(music_id, &song);
songs_changed += 1;
}
}
if songs_changed > 0 {
database::bump_revision();
println!("Custom song spawn-group migration: rewrote {} chart(s) in {} song(s), catalog revision bumped", charts_changed, songs_changed);
}
}
+42 -8
View File
@@ -41,11 +41,40 @@ pub fn build(music_id: i64) -> Result<Vec<u8>, String> {
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()?;
// Reads one entry, capped. Deflate's ceiling is about 1032:1, so an uncapped
// read_to_end here is a zip bomb: a one-megabyte entry inflates to a gigabyte and
// grows the Vec until the allocator or the OOM killer stops it. Every entry is
// bounded by the upload form's own per-file cap, and by what is left of the
// per-request budget across all entries.
//
// The central directory's declared size rejects the obvious case without
// inflating anything; take(cap + 1) makes that declaration untrusted - a lying
// header runs out of budget one byte past the cap and stops there.
//
// Ok(None) is "the package does not carry this entry", which is a normal outcome
// for the optional ones
fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str, remaining: &mut usize) -> Result<Option<Vec<u8>>, String> {
let Ok(mut file) = archive.by_name(name) else {
return Ok(None);
};
let cap = std::cmp::min(super::MAX_FILE_BYTES, *remaining);
// Which limit the entry actually ran into, so the message names the right one
let too_big = if cap >= super::MAX_FILE_BYTES {
super::over_file_limit(name)
} else {
super::over_request_limit()
};
if file.size() > cap as u64 {
return Err(too_big);
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).ok()?;
Some(bytes)
file.by_ref().take(cap as u64 + 1).read_to_end(&mut bytes)
.map_err(|_| format!("Package entry '{}' could not be read", name))?;
if bytes.len() > cap {
return Err(too_big);
}
*remaining -= bytes.len();
Ok(Some(bytes))
}
// Expands a package into the same field map the upload form produces - the zip
@@ -55,8 +84,10 @@ fn read_entry<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> O
// visibility/shared_with/downloads_disabled aren't packaged and stay untouched
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"))?;
// The decompressed budget for the whole package, shared by every entry
let mut remaining = super::MAX_REQUEST_BYTES;
let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?;
let manifest = read_entry(&mut archive, "manifest.json", &mut remaining)?.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"));
@@ -67,6 +98,9 @@ pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(
fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
}
}
// Group IDs are server-local. The package records the original ID for
// reference, but only the destination upload form may choose its group;
// copying the ID could silently select a different band's row there.
for data in manifest["levels"].members() {
let Some(level) = data["level"].as_i64() else { continue; };
if !data["level_number"].is_null() {
@@ -74,11 +108,11 @@ pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(
}
}
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket").ok_or(String::from("Package has no jacket"))?);
fields.insert(String::from("audio"), read_entry(&mut archive, "audio").ok_or(String::from("Package has no audio"))?);
fields.insert(String::from("jacket"), read_entry(&mut archive, "jacket", &mut remaining)?.ok_or(String::from("Package has no jacket"))?);
fields.insert(String::from("audio"), read_entry(&mut archive, "audio", &mut remaining)?.ok_or(String::from("Package has no audio"))?);
let mut has_chart = false;
for level in 1..=LEVEL_COUNT {
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level)) {
if let Some(chart) = read_entry(&mut archive, &format!("chart_{}.json", level), &mut remaining)? {
fields.insert(format!("chart_{}", level), chart);
has_chart = true;
}
+6 -6
View File
@@ -1,6 +1,6 @@
_id,_prefabName,_masterBgmId,_priority,_masterReleaseLabelId
1,DefaultTitle1,21000001,0,1
2,DefaultTitle2,21000001,0,1
3,DefaultTitle3,21000001,0,1
4,DefaultTitle4,21000001,0,1
5,DefaultTitle5,21000001,0,1
_id,_imageName,_logoX,_logoY,_masterBgmId,_priority,_masterReleaseLabelId
1,titlescreen_0001,181,-112,21000001,0,1
2,titlescreen_0002,-609.44,300.61,21000001,0,1
3,titlescreen_0003,0,264,21000001,0,1
4,titlescreen_0004,-343,-282.61,21000001,0,1
5,titlescreen_0005,0,346,21000001,0,1
1 _id _prefabName _imageName _logoX _logoY _masterBgmId _priority _masterReleaseLabelId
2 1 DefaultTitle1 titlescreen_0001 181 -112 21000001 0 1
3 2 DefaultTitle2 titlescreen_0002 -609.44 300.61 21000001 0 1
4 3 DefaultTitle3 titlescreen_0003 0 264 21000001 0 1
5 4 DefaultTitle4 titlescreen_0004 -343 -282.61 21000001 0 1
6 5 DefaultTitle5 titlescreen_0005 0 346 21000001 0 1
+6 -6
View File
@@ -1,6 +1,6 @@
_id,_prefabName,_masterBgmId,_priority,_masterReleaseLabelId
1,DefaultTitle1,21000001,0,1
2,DefaultTitle2,21000001,0,1
3,DefaultTitle3,21000001,0,1
4,DefaultTitle4,21000001,0,1
5,DefaultTitle5,21000001,0,1
_id,_imageName,_logoX,_logoY,_masterBgmId,_priority,_masterReleaseLabelId
1,titlescreen_0001,181,-112,21000001,0,1
2,titlescreen_0002,-609.44,300.61,21000001,0,1
3,titlescreen_0003,0,264,21000001,0,1
4,titlescreen_0004,-343,-282.61,21000001,0,1
5,titlescreen_0005,0,346,21000001,0,1
1 _id _prefabName _imageName _logoX _logoY _masterBgmId _priority _masterReleaseLabelId
2 1 DefaultTitle1 titlescreen_0001 181 -112 21000001 0 1
3 2 DefaultTitle2 titlescreen_0002 -609.44 300.61 21000001 0 1
4 3 DefaultTitle3 titlescreen_0003 0 264 21000001 0 1
5 4 DefaultTitle4 titlescreen_0004 -343 -282.61 21000001 0 1
6 5 DefaultTitle5 titlescreen_0005 0 346 21000001 0 1
+9 -1
View File
@@ -5676,9 +5676,17 @@
"type": "uint"
},
{
"name": "_prefabName",
"name": "_imageName",
"type": "string"
},
{
"name": "_logoX",
"type": "float"
},
{
"name": "_logoY",
"type": "float"
},
{
"name": "_masterBgmId",
"type": "uint"
+1
View File
@@ -198,6 +198,7 @@ fn init_star_event(event: &mut JsonValue) {
async fn event(Session { key, body }: Session) -> impl Responder {
let master_event_id = body["master_event_id"].as_u32().unwrap();
super::story::visit_event(&key, master_event_id);
let mut event = get_event_data(&key, master_event_id);
let is_star_event = STAR_EVENT_IDS.contains(&master_event_id);
+92 -11
View File
@@ -40,15 +40,41 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
AssetVersion { region: "JP", platform: "Android", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "ed8f4b8df9d3935d689e1236a6816b2b", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "407a8fd81cc5f525ad12c957dbefccb2", latest: false },
// Re-written client versions 2.4.0 -
AssetVersion { region: "JP", platform: "Windows", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "73c12d3a4986013afa1eee65469124f7", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "7d98640952f358554306658c2522d7f6", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "7d7c80f2a8e45789c1f391879710bdbc", latest: true },
// Re-written client versions 2.4.0 - 2.4.2
AssetVersion { region: "JP", platform: "Windows", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "d1b4db937c1af8364d38f08296cbcfae", latest: false },
AssetVersion { region: "JP", platform: "Android", version: "7d9c2e5efa794a048c11bcaf781ace79", hash: "8fcb61a08e69d854438c4f318d38cabd", latest: false },
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: false },
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: false },
// Re-written client versions 2.6.0 - 2.6.1
AssetVersion { region: "JP", platform: "Windows", version: "7c74b0df372a460d82378e55dc456260", hash: "8941d32738752c4ca932a28d866852ba", latest: false },
AssetVersion { region: "JP", platform: "Android", version: "7c74b0df372a460d82378e55dc456260", hash: "f5ac6238570d8ff8351fda9c79e8774a", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "7c74b0df372a460d82378e55dc456260", hash: "b77459c89ec94acc4fddc787ded62b95", latest: false },
// Re-written client versions 2.7.0 -
AssetVersion { region: "JP", platform: "Windows", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4cbd26690102b6b98f5886263bdfefcd", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "4e64250bb32af39247fe7d7cdb840982", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "96615485374096183e4e0ce900671910", latest: true },
AssetVersion { region: "JP", platform: "Linux", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
AssetVersion { region: "JP", platform: "macOS", version: "a984f9b4c8b64f3e81f0bf11cb9c26fb", hash: "", latest: true },
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
];
impl AssetVersion {
fn accepts_override(&self) -> bool {
// The retired GL clients can still use a custom version/hash, but they are not
// current releases. Keep override eligibility separate from update policy.
self.latest || (self.region == "GL"
&& self.version == "5260ff15dff8ba0c00ad91400f515f55")
}
fn stock_hash(&self) -> String {
if self.latest && self.platform == "Android" && get_easter_mode() {
if let Some((_, easter)) = EASTER_HASHES.iter().find(|(r, _)| *r == self.region) {
@@ -60,9 +86,15 @@ impl AssetVersion {
fn override_pair(&self) -> Option<(String, String)> {
let args = crate::get_args();
let ov = args.asset_version.as_str();
let ov = if self.region == "GL" {
args.en_asset_version.as_str()
} else {
args.asset_version.as_str()
};
let oh = match (self.region, self.platform) {
("JP", "Windows") => args.windows_asset_hash.as_str(),
("JP", "Linux") => args.linux_asset_hash.as_str(),
("JP", "macOS") => args.macos_asset_hash.as_str(),
("JP", "Android") => args.jp_android_asset_hash.as_str(),
("JP", "iOS") => args.jp_ios_asset_hash.as_str(),
("GL", "Android") => args.en_android_asset_hash.as_str(),
@@ -79,15 +111,18 @@ impl AssetVersion {
}
fn valid_hashes(asset_version: &str, platform: &str) -> Vec<String> {
let args = crate::get_args();
let mut out = Vec::new();
for entry in ASSET_VERSIONS {
if entry.platform != platform {
continue;
}
if entry.version == asset_version {
out.push(entry.stock_hash());
if entry.version == asset_version && !entry.hash.is_empty() {
if entry.latest || !args.force_updates {
out.push(entry.stock_hash());
}
}
if entry.latest {
if entry.accepts_override() {
if let Some((ov, oh)) = entry.override_pair() {
if ov == asset_version {
out.push(oh);
@@ -104,14 +139,14 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option<String> {
if entry.platform != platform {
continue;
}
if entry.latest {
if entry.accepts_override() {
if let Some((ov, oh)) = entry.override_pair() {
if ov == asset_version {
return Some(oh);
}
}
}
if entry.version == asset_version && stock.is_none() {
if entry.version == asset_version && stock.is_none() && !entry.hash.is_empty() {
stock = Some(entry.stock_hash());
}
}
@@ -144,7 +179,7 @@ pub fn get_player_region(asset_version: &str) -> Option<String> {
if entry.version == asset_version {
return Some(entry.region.to_string());
}
if entry.latest {
if entry.accepts_override() {
if let Some((ov, _)) = entry.override_pair() {
if ov == asset_version {
return Some(entry.region.to_string());
@@ -164,6 +199,8 @@ pub fn parse_platform(header: &str) -> &str {
"iphone" => "iOS",
"windows" => "Windows",
"windowsplayer" => "Windows",
"linuxplayer" => "Linux",
"osxplayer" => "macOS",
"webglplayer" => "WebGL",
"editor" => "Editor",
"windowseditor" => "Editor",
@@ -387,6 +424,10 @@ pub fn send(mut data: JsonValue, uid: i64, req: &HttpRequest) -> HttpResponse {
//println!("{}", jzon::stringify(data.clone()));
data["server_time"] = set_time(data["server_time"].as_u64().unwrap_or(0), uid, true).into();
if data["data"].is_object() {
super::mission::filter_response(&mut data["data"], req.headers());
}
if !data["data"]["item_list"].is_empty() || !data["data"]["updated_value_list"]["item_list"].is_empty() {
items::check_for_region(&mut data, req.headers());
}
@@ -466,3 +507,43 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue {
}
rv
}
#[cfg(test)]
mod platform_tests {
use super::*;
#[test]
fn host_version_overrides_keep_jp_and_global_distinct() {
crate::runtime::apply_config_json(
r#"{"assetVersion":"jp-test-version","enAssetVersion":"gl-test-version","jpAndroidAssetHash":"jp-test-hash","enAndroidAssetHash":"gl-test-hash"}"#,
);
assert_eq!(get_player_region("jp-test-version").as_deref(), Some("JP"));
assert_eq!(get_player_region("gl-test-version").as_deref(), Some("GL"));
assert_eq!(preferred_hash("jp-test-version", "Android").as_deref(), Some("jp-test-hash"));
assert_eq!(preferred_hash("gl-test-version", "Android").as_deref(), Some("gl-test-hash"));
assert!(valid_hashes("gl-test-version", "Android").contains(&"gl-test-hash".to_string()));
assert!(!ASSET_VERSIONS.iter().any(|entry| entry.region == "GL" && entry.latest));
crate::runtime::apply_config_json("{}");
}
#[test]
fn standalone_player_platforms_are_known() {
assert_eq!(parse_platform("WindowsPlayer"), "Windows");
assert_eq!(parse_platform("LinuxPlayer"), "Linux");
assert_eq!(parse_platform("OSXPlayer"), "macOS");
assert_eq!(parse_platform("OSXEditor"), "Editor");
}
// Linux / macOS have no baked hash: without the CLI override the platform has no valid
// hash at all (never an empty string presented as one)
#[test]
fn unhashed_platforms_answer_nothing_without_an_override() {
let latest = ASSET_VERSIONS.iter().find(|e| e.latest && e.platform == "Windows").unwrap().version;
assert_eq!(preferred_hash(latest, "macOS"), None);
assert_eq!(preferred_hash(latest, "Linux"), None);
assert!(valid_hashes(latest, "macOS").is_empty());
assert!(preferred_hash(latest, "Windows").is_some());
}
}
+28 -3
View File
@@ -194,14 +194,34 @@ async fn payment_ticket(req: HttpRequest) -> impl Responder {
async fn migration_verify(req: HttpRequest, body: String) -> impl Responder {
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 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 {
// 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!{
result: "ERR",
messsage: "User Not Found"
result: "NG",
code: code,
message: message
}
} else {
let data_user = userdata::get_acc(&user["login_token"].to_string());
@@ -304,6 +324,11 @@ async fn migration_password_register(req: HttpRequest, body: String) -> impl Res
let pass = decrypt_transfer_password(&body["migration_password"].to_string());
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &pass);
if !pass.is_empty() {
let mut missions = userdata::get_acc_missions(&uid);
super::beginner_mission::advance(25, None, 1, &mut missions);
userdata::save_acc_missions(&uid, missions);
}
let resp = object!{
result: "OK"
+18 -6
View File
@@ -23,6 +23,10 @@ async fn preset(Session { key, body }: Session) -> impl Responder {
}
userdata::save_acc_home(&key, user);
let mut missions = userdata::get_acc_missions(&key);
super::beginner_mission::advance(61, None, 1, &mut missions);
userdata::save_acc_missions(&key, missions);
Api(Some(array![]))
}
@@ -84,17 +88,20 @@ lazy_static! {
}
async fn home(Login(key): Login) -> impl Responder {
let account = userdata::get_acc(&key);
super::chat::refresh_birthday(&key, &account);
let mut user = userdata::get_acc_home(&key);
check_gifts(&mut user);
let mut user_missions = userdata::get_acc_missions(&key);
let clear = items::completed_daily_mission(1253003, &mut user_missions);
let mut clear = super::beginner_mission::refresh_account(&account, &mut user_missions);
for id in items::completed_daily_mission(1253003, &mut user_missions).members() {
clear.push(id.clone()).unwrap();
}
userdata::save_acc_home(&key, user.clone());
user["clear_mission_ids"] = clear;
if !user["clear_mission_ids"].is_empty() {
userdata::save_acc_missions(&key, user_missions.clone());
}
userdata::save_acc_missions(&key, user_missions.clone());
let daily_missions = array![1224003, 1253003, 1273009, 1273010, 1273011, 1273012];
@@ -124,8 +131,13 @@ async fn home(Login(key): Login) -> impl Responder {
}
user["home"]["unread_chat_count"] = chat_count.into();
//todo
user["home"]["beginner_mission_complete"] = 1.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();
let (ready, complete) = super::beginner_mission::home_status(&user_missions);
user["home"]["clear_beginner_mission_count"] = ready.into();
user["home"]["beginner_mission_complete"] = (complete as i32).into();
Api(Some(user))
}
+2 -13
View File
@@ -422,17 +422,6 @@ pub fn update_mission_status(master_mission_id: i64, expire: u64, completed: boo
None
}
pub fn update_mission_status_multi(master_mission_id: JsonValue, expire: u64, completed: bool, claimed: bool, advance: i64, missions: &mut JsonValue) -> JsonValue {
let mut rv = array![];
for mission in master_mission_id.members() {
let val = update_mission_status(mission.as_i64().unwrap(), expire, completed, claimed, advance, missions);
if let Some(val2) = val {
rv.push(val2).unwrap();
}
}
rv
}
pub fn get_mission_status(id: i64, missions: &JsonValue) -> JsonValue {
for mission in missions.members() {
if mission["master_mission_id"].as_i64().unwrap() == id {
@@ -545,8 +534,8 @@ pub fn completed_daily_mission(id: i64, missions: &mut JsonValue) -> JsonValue {
let mut rv = array![];
if id == 1253003 {
rv = advance_variable_mission(1153001, 1153019, 1, missions);
if update_mission_status(1653001, 0, true, false, 1, missions).is_some() {
rv.push(1653001).unwrap();
for id in super::beginner_mission::advance(53, None, 1, missions).members() {
rv.push(id.clone()).unwrap();
}
}
for mission in missions.members_mut() {
+40 -1
View File
@@ -3,7 +3,7 @@ use actix_web::{web, HttpRequest, Responder};
use rand::RngExt;
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::tools::guest;
@@ -25,7 +25,17 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
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);
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 {
// Body-derived, so defaulted rather than unwrapped: a retire is not worth a
// 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 {
// 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);
Api(Some(array![]))
}
@@ -713,6 +727,17 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
//1273009, 1273010, 1273011, 1273012
let mut cleared_missions = items::advance_variable_mission(1105001, 1105017, 1, &mut user_missions);
for id in super::beginner_mission::advance(5, None, 1, &mut user_missions).members() {
cleared_missions.push(id.clone()).unwrap();
}
if !skipped {
let started = get_started_live(key, body).unwrap_or(JsonValue::Null);
if started["is_omakase"] == 1 {
for id in super::beginner_mission::advance(62, None, 1, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); }
}
let boost = started["live_boost"].as_i64().or(body["live_boost"].as_i64()).unwrap_or(0);
for id in super::beginner_mission::advance(6, Some(boost), 1, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); }
}
if body["master_live_id"].to_string().len() > 1 {
let id = body["master_live_id"].to_string().split("").collect::<Vec<_>>()[2].parse::<i64>().unwrap_or(0);
if (1..=4).contains(&id) {
@@ -808,6 +833,8 @@ pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool
let deck_slot = get_end_live_deck_id(&key, &body).unwrap_or(body["deck_slot"].as_i32().unwrap_or(user["user"]["main_deck_slot"].as_i32().unwrap()));
let characters = get_live_character_list(lp_used, deck_slot, &mut user, &mut user_missions, &mut cleared_missions, &mut chats);
for id in super::beginner_mission::refresh(&user, &mut user_missions).members() { cleared_missions.push(id.clone()).unwrap(); }
super::story::refresh(&user, &mut user_missions);
userdata::save_acc(&key, user.clone());
userdata::save_acc_home(&key, user2.clone());
@@ -840,6 +867,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 {
// "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)))
}
+9 -6
View File
@@ -390,6 +390,9 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
}
add_draw_count(&mut user, lottery_id, price_number);
for id in super::beginner_mission::advance(63, Some(lottery_id), price["count"].as_i64().unwrap_or(0), &mut missions).members() {
cleared_missions.push(id.clone()).unwrap();
}
let mut new_count = get_draw_count(&user, lottery_id, price_number);
if is_stepup(lottery_id) && price_number == 1 {
new_count += 1;
@@ -427,7 +430,7 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl
}
// tests!!!
#[cfg(test)]
mod tests {
@@ -447,18 +450,18 @@ mod tests {
let mut r1_ids = Vec::new();
for seed in 0..3 {
let id = custom_card_db::next_card_id();
custom_card_db::insert_card(id, 1001, 6001, &object!{ "master_card_id": id, "rarity": 1, "seed": seed }, true, true);
custom_card_db::insert_card(id, 1001, 6001, &object!{ "master_card_id": id, "rarity": 1, "seed": seed }, true, true).unwrap();
r1_ids.push(id);
}
let r2 = custom_card_db::next_card_id();
custom_card_db::insert_card(r2, 1001, 6001, &object!{ "master_card_id": r2, "rarity": 2 }, true, true);
custom_card_db::insert_card(r2, 1001, 6001, &object!{ "master_card_id": r2, "rarity": 2 }, true, true).unwrap();
let r3 = custom_card_db::next_card_id();
custom_card_db::insert_card(r3, 1001, 6001, &object!{ "master_card_id": r3, "rarity": 3 }, true, true);
custom_card_db::insert_card(r3, 1001, 6001, &object!{ "master_card_id": r3, "rarity": 3 }, true, true).unwrap();
// Draft / unobtainable cards must never come out of the pool
let draft = custom_card_db::next_card_id();
custom_card_db::insert_card(draft, 1001, 6001, &object!{ "master_card_id": draft, "rarity": 1 }, false, true);
custom_card_db::insert_card(draft, 1001, 6001, &object!{ "master_card_id": draft, "rarity": 1 }, false, true).unwrap();
let unobtainable = custom_card_db::next_card_id();
custom_card_db::insert_card(unobtainable, 1001, 6001, &object!{ "master_card_id": unobtainable, "rarity": 1 }, true, false);
custom_card_db::insert_card(unobtainable, 1001, 6001, &object!{ "master_card_id": unobtainable, "rarity": 1 }, true, false).unwrap();
let drawn = custom_banner_cards(11);
assert_eq!(drawn.len(), 11);
+231 -7
View File
@@ -3,6 +3,29 @@ use actix_web::{web, HttpRequest, Responder};
use crate::router::{global, userdata, items, databases, Login, Session, Api};
pub fn filter_response(data: &mut JsonValue, headers: &actix_web::http::header::HeaderMap) {
if items::get_region(headers) { return; }
lazy_static::lazy_static! {
static ref GLOBAL_IDS: std::collections::HashSet<i64> =
databases::csv::table(databases::csv::Region::En, "mission")
.members().filter_map(|row| row["id"].as_i64()).collect();
}
// Only project the response. Shared JP/global accounts keep their progress.
for field in ["mission_list", "clear_mission_ids"] {
if !data[field].is_array() { continue; }
for i in (0..data[field].len()).rev() {
let id = if field == "mission_list" { data[field][i]["master_mission_id"].as_i64() }
else { data[field][i].as_i64() };
if !id.is_some_and(|id| GLOBAL_IDS.contains(&id)) {
data[field].array_remove(i);
}
}
}
if data["updated_value_list"].is_object() {
filter_response(&mut data["updated_value_list"], headers);
}
}
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/mission")
@@ -12,6 +35,161 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
);
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn mission_endpoint_filters_global_wire_response_not_saved_account() {
let _test = crate::runtime::lock_test_data_path();
let (uid, key) = userdata::starter::create("Regional missions test").unwrap();
let mut user = userdata::get_acc(&key);
user["tutorial_step"] = 999.into();
userdata::save_acc(&key, user);
for version in ["5260ff15dff8ba0c00ad91400f515f55", "4c921d2443335e574a82e04ec9ea243c"] {
let request = actix_web::test::TestRequest::default()
.insert_header(("aoharu-user-id", uid.to_string()))
.insert_header(("aoharu-asset-version", version)).to_http_request();
let response = mission(Login(key.clone())).await.respond_to(&request);
let body = actix_web::body::to_bytes(response.into_body()).await.ok().unwrap();
let plain = crate::encryption::decrypt_packet(std::str::from_utf8(&body).unwrap()).unwrap();
let response = jzon::parse(&plain).unwrap();
assert_eq!(response["code"], 0);
let missions = &response["data"]["mission_list"];
assert!(missions.members().any(|m| m["master_mission_id"] == 1073001));
assert_eq!(missions.members().any(|m| m["master_mission_id"] == 1073037),
items::get_region(request.headers()));
assert!(userdata::get_acc_missions(&key).members()
.any(|m| m["master_mission_id"] == 1073037));
}
}
#[test]
fn global_missions_match_client_masterdata_without_losing_saved_progress() {
let request = actix_web::test::TestRequest::default()
.insert_header(("aoharu-asset-version", "5260ff15dff8ba0c00ad91400f515f55"))
.to_http_request();
assert!(!items::get_region(request.headers()));
let mut saved = array![];
super::super::story::refresh(&object! {live_list: []}, &mut saved);
let original = saved.clone();
let ids = databases::csv::table(databases::csv::Region::En, "mission");
let known: std::collections::HashSet<_> = ids.members()
.filter_map(|r| r["id"].as_i64()).collect();
let absent: Vec<_> = saved.members().filter_map(|m| {
let id = m["master_mission_id"].as_i64().unwrap();
(!known.contains(&id)).then_some(id)
}).collect();
assert_eq!(absent.len(), 30);
assert!(absent.iter().all(|id| (1073037..=1073066).contains(id)));
let expected: JsonValue = saved.members().filter(|m|
known.contains(&m["master_mission_id"].as_i64().unwrap()))
.cloned().collect::<Vec<_>>().into();
let mut response = object! {mission_list: saved.clone(),
clear_mission_ids: [1073037, 1073001, 1073066],
updated_value_list: {mission_list: saved.clone(), clear_mission_ids: [1073001, 1073037]},
reward_list: [{type: 1, amount: 100}]};
filter_response(&mut response, request.headers());
assert_eq!(response["mission_list"], expected);
assert_eq!(response["updated_value_list"]["mission_list"], expected);
assert_eq!(response["clear_mission_ids"], array![1073001]);
assert_eq!(response["updated_value_list"]["clear_mission_ids"], array![1073001]);
assert_eq!(response["reward_list"], array![object! {type: 1, amount: 100}]);
assert_eq!(saved, original);
let mut unrelated = object! {item_list: [], value: 42};
let before = unrelated.clone();
filter_response(&mut unrelated, request.headers());
assert_eq!(unrelated, before);
let mut jp = object! {mission_list: saved};
let before = jp.clone();
filter_response(&mut jp, actix_web::test::TestRequest::default().to_http_request().headers());
assert_eq!(jp, before);
}
#[actix_web::test]
async fn bond_title_claim_notifies_client_and_persists() {
let _test = crate::runtime::lock_test_data_path();
let (uid, key) = userdata::starter::create("Bond title test").unwrap();
let request = || actix_web::test::TestRequest::default()
.insert_header(("aoharu-user-id", uid.to_string())).to_http_request();
let initial_titles = userdata::get_acc(&key)["master_title_ids"].clone();
userdata::save_acc_missions(&key, array![object! {
master_mission_id: 1158015, status: 2, progress: 1500, expire_date_time: 0
}]);
let response = receive(request(), Session {
key: key.clone(), body: object! {master_mission_ids: [1158015, 1158015]}
}).await.0.unwrap();
assert_eq!(response["reward_list"].len(), 1);
assert_eq!(response["reward_list"][0]["type"], 8);
assert_eq!(response["reward_list"][0]["value"], 3000015);
assert_eq!(response["updated_value_list"]["master_title_ids"], array![3000015]);
assert!(response["updated_value_list"]["item_list"].is_null());
let saved = userdata::get_acc(&key);
assert!(saved["master_title_ids"].contains(3000015));
for title in initial_titles.members() {
assert!(saved["master_title_ids"].contains(title.as_i64().unwrap()));
}
let replay = receive(request(), Session {
key: key.clone(), body: object! {master_mission_ids: [1158015]}
}).await.0.unwrap();
assert!(replay["reward_list"].is_empty());
assert!(replay["updated_value_list"]["master_title_ids"].is_null());
assert_eq!(userdata::get_acc(&key), saved);
let mut missions = userdata::get_acc_missions(&key);
items::update_mission_status(1158054, 0, true, false, 13500, &mut missions);
userdata::save_acc_missions(&key, missions);
let next = receive(request(), Session {
key: key.clone(), body: object! {master_mission_ids: [1158054]}
}).await.0.unwrap();
assert_eq!(next["updated_value_list"]["master_title_ids"], array![3000054]);
let saved = userdata::get_acc(&key);
assert!(saved["master_title_ids"].contains(3000015));
assert!(saved["master_title_ids"].contains(3000054));
}
#[actix_web::test]
async fn beginner_claims_persist_rewards_and_reject_repeats() {
let _test = crate::runtime::lock_test_data_path();
let (uid, key) = userdata::starter::create("Beginner test").unwrap();
let request = || actix_web::test::TestRequest::default()
.insert_header(("aoharu-user-id", uid.to_string())).to_http_request();
let first = [1653001,1605001,1662001,1663001,1660001,1614001,1666001,1658001,1661001];
let mut missions = userdata::get_acc_missions(&key);
for id in first {
super::super::beginner_mission::set_progress(id, i64::MAX, &mut missions);
}
super::super::beginner_mission::set_progress(1653002, 2, &mut missions);
userdata::save_acc_missions(&key, missions);
let blocked = receive(request(), Session { key: key.clone(), body: object! {master_mission_ids: [1653002]} }).await.0.unwrap();
assert!(blocked["reward_list"].is_empty());
let gem_before = userdata::get_acc(&key)["gem"]["total"].as_i64().unwrap();
let mut ids: Vec<i64> = first.into();
ids.push(first[0]);
let body = object! {master_mission_ids: ids};
let response = receive(request(), Session { key: key.clone(), body: body.clone() }).await.0.unwrap();
assert_eq!(response["mission_list"].len(), 9);
assert_eq!(response["reward_list"].len(), 18);
let bonuses: i64 = response["reward_list"].members().filter(|r| r["type"] == 1)
.map(|r| r["amount"].as_i64().unwrap()).sum();
assert_eq!(bonuses, 1000);
assert_eq!(userdata::get_acc(&key)["gem"]["total"].as_i64().unwrap(), gem_before + bonuses);
let items = &response["updated_value_list"]["item_list"];
let unique: std::collections::HashSet<_> = items.members().map(|i| i["master_item_id"].as_i64()).collect();
assert_eq!(items.len(), unique.len());
let saved = userdata::get_acc(&key);
let replay = receive(request(), Session { key: key.clone(), body }).await.0.unwrap();
assert!(replay["reward_list"].is_empty());
assert_eq!(userdata::get_acc(&key), saved);
let second = receive(request(), Session { key: key.clone(), body: object! {master_mission_ids: [1653002, 1605002]} }).await.0.unwrap();
assert_eq!(second["mission_list"].len(), 1);
assert_eq!(second["mission_list"][0]["master_mission_id"], 1653002);
assert_eq!(second["mission_list"][0]["progress"], 2);
}
}
const VARIABLE_MISSIONS: [[i64; 2]; 5] = [[1153001, 1153019], [1105001, 1105017], [1101001, 1101030], [1121001, 1121019], [1112001, 1112033]];
async fn mission(Login(key): Login) -> impl Responder {
@@ -25,9 +203,10 @@ async fn mission(Login(key): Login) -> impl Responder {
}
let mut missions = userdata::get_acc_missions(&key);
if items::refresh_dailies(&mut missions, global::timestamp()) {
userdata::save_acc_missions(&key, missions.clone());
}
items::refresh_dailies(&mut missions, global::timestamp());
super::story::refresh(&user, &mut missions);
super::beginner_mission::refresh_account(&user, &mut missions);
userdata::save_acc_missions(&key, missions.clone());
Api(Some(object!{
"mission_list": missions
@@ -39,6 +218,10 @@ async fn clear(Session { key, body }: Session) -> impl Responder {
let mut cleared = array![];
for id in body["master_mission_ids"].members() {
if super::beginner_mission::contains(id.as_i64().unwrap_or(0)) {
let condition = databases::MISSION_LIST[id.to_string()]["conditionType"].as_i64();
if !matches!(condition, Some(26 | 65 | 66)) { continue; }
}
let mission = items::get_mission_status(id.as_i64().unwrap(), &missions);
if mission.is_empty() || mission["status"].as_i32().unwrap_or(0) >= 2 {
continue;
@@ -54,7 +237,11 @@ async fn clear(Session { key, body }: Session) -> impl Responder {
}))
}
async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
static CLAIM_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
async fn receive(req: HttpRequest, Session { key, body }: Session) -> Api {
// Keep the read/grant/save sequence exclusive across HTTP workers.
let _claim = crate::lock_onto_mutex!(CLAIM_LOCK);
let uid = global::get_uid(req.headers());
let now = global::timestamp();
@@ -66,9 +253,21 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
let mut touched_gem = false;
let mut touched_coin = false;
let mut touched_items = array![];
let mut touched_titles = std::collections::BTreeSet::new();
let before = missions.clone();
let mut requested: Vec<_> = body["master_mission_ids"].members().filter_map(|id| id.as_i64()).collect();
let mut seen = std::collections::HashSet::new();
requested.retain(|id| seen.insert(*id));
// Process beginner cells in sheet order so a single claim can unlock the next sheet.
requested.sort_by_key(|id| super::beginner_mission::level(*id));
for mission in body["master_mission_ids"].members() {
let mid = mission.as_i64().unwrap();
for mid in requested {
if items::get_mission_status(mid, &missions)["status"] != 2 {
continue;
}
if super::beginner_mission::contains(mid) && !super::beginner_mission::claimable(mid, &missions) {
continue;
}
let mission_info = databases::MISSION_LIST[mid.to_string()].clone();
for master in databases::MISSION_REWARDS[mission_info["masterMissionRewardId"].to_string()].members() {
let reward_type = master["type"].as_i64().unwrap();
@@ -90,6 +289,7 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
match reward_type {
1 => touched_gem = true,
4 => touched_coin = true,
8 => { touched_titles.insert(master["value"].as_i64().unwrap()); }
_ => { touched_items.push(master["value"].clone()).unwrap(); }
}
}
@@ -135,24 +335,45 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
user_id: uid,
master_mission_id: mid,
status: 3,
progress: if expire != 0 { m["progress"].clone() } else { JsonValue::Null },
progress: if expire != 0 || super::beginner_mission::is_counter(mid) { m["progress"].clone() } else { JsonValue::Null },
expire_date: if expire != 0 { global::format_datetime(expire).into() } else { JsonValue::Null },
received_date: global::format_datetime(now)
}).unwrap();
}
}
for master in super::beginner_mission::new_rewards(&before, &missions) {
let reward_type = master["type"].as_i64().unwrap();
rewards.push(object! {
give_type: master["giveType"].clone(), type: master["type"].clone(),
value: master["value"].clone(), level: master["level"].clone(), amount: master["amount"].clone()
}).unwrap();
items::give_gift(&object! {
reward_type: reward_type, value: master["value"].clone(), amount: master["amount"].clone()
}, &mut user, &mut missions, &mut array![], &mut chats);
match reward_type {
1 => touched_gem = true,
4 => touched_coin = true,
8 => { touched_titles.insert(master["value"].as_i64().unwrap()); }
_ => { touched_items.push(master["value"].clone()).unwrap(); }
}
}
userdata::save_acc(&key, user.clone());
userdata::save_acc_chats(&key, chats);
userdata::save_acc_missions(&key, missions);
let mut updated_value_list = object!{};
if !touched_titles.is_empty() {
updated_value_list["master_title_ids"] = touched_titles.into_iter().collect::<Vec<_>>().into();
}
if touched_gem {
updated_value_list["gem"] = user["gem"].clone();
}
if !touched_items.is_empty() {
let mut item_list = array![];
let mut seen = std::collections::HashSet::new();
for id in touched_items.members() {
if !seen.insert(id.as_i64()) { continue; }
for item in user["item_list"].members() {
if item["master_item_id"] == *id {
item_list.push(item.clone()).unwrap();
@@ -176,6 +397,9 @@ async fn receive(req: HttpRequest, Session { key, body }: Session) -> impl Respo
updated_value_list["point_list"] = point_list;
}
let mut sorted: Vec<_> = mission_list.members().cloned().collect();
sorted.sort_by_key(|m| m["master_mission_id"].as_i64());
let mission_list: JsonValue = sorted.into();
Api(Some(object!{
"reward_list": rewards,
"gift_list": array![],
+10 -226
View File
@@ -62,7 +62,7 @@ fn const_value(id: &str, default: i64) -> i64 {
.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"]
.as_i64()
.unwrap_or(10 * live_boost)
@@ -289,10 +289,8 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
return Api(None);
}
// `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
// from — the one thing read out of it is the room's privacy, and that is read here
// 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
// party members post. It is recorded verbatim on the start record and nothing reads
// anything else out of it. Reconciling the party itself — checking the four results
// against each other — is still deferred.
// 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
@@ -329,7 +327,6 @@ async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Res
userdata::save_acc(&key, user);
body["use_lp"] = consumed.into();
record_room_privacy(&mut body);
live::start_live(&key, &body);
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 {
// Older clients speak an incompatible multi — refuse before touching any state
// (in particular before the start record can be consumed).
@@ -452,27 +404,19 @@ async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder
// live_end scales exp / gold / bond / mission rewards off.
let mut end_body = body.clone();
end_body["use_lp"] = lp_used.into();
end_body["live_boost"] = live_boost.into();
if end_body["deck_slot"].is_null() {
if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) {
end_body["deck_slot"] = slot.into();
}
}
// A public room 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
// matter how late its POST lands. Only a record from before that was recorded falls
// back to a live lookup, where a room the registry no longer knows about (torn down,
// or a restart since the live started) is treated as public — the behaviour to be
// wrong on.
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);
// A multi live scores like the official server did: no high score, no score board —
// the client's own result screen says so. Private (join-by-code) parties are no
// exception (Ethan 2026-08-12; an earlier build recorded private-party scores, and
// the room-privacy plumbing that told the two apart left with that behaviour). The
// clear count and max combo still record either way — the live really was played.
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, false);
rv["is_penalty_miss_ratio"] = status.into();
// Fields RecvMultiLiveEndRData declares that live_end does not emit.
@@ -837,166 +781,6 @@ mod tests {
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]
fn player_count_does_not_double_count_the_poster() {
let body = object!{
+2
View File
@@ -104,6 +104,7 @@ impl Value {
}
}
#[allow(dead_code)]
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(v) => Some(v),
@@ -148,6 +149,7 @@ impl Map {
self.get(key).and_then(Value::as_int)
}
#[allow(dead_code)]
pub fn get_str(&self, key: &str) -> Option<&str> {
self.get(key).and_then(Value::as_str)
}
+1 -141
View File
@@ -58,13 +58,6 @@ pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(90);
const PROP_ROOM_LEVEL: &str = "C0";
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
// 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
@@ -133,18 +126,6 @@ struct Room {
max_players: u8,
visible: 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,
// 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.
@@ -431,15 +412,12 @@ impl Registry {
// disagree the bag wins, because the bag is what every client polls.
let (mut visible, mut open) = (visible, 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.
let room = Room {
lobby,
max_players,
visible,
open,
private,
props,
lobby_prop_keys,
// 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) ----------------------
// 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)]
pub fn room_count(&self) -> usize {
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
// 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)
}
// This file is like 1.5k lines of tests :skull:
#[cfg(test)]
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]
fn disconnect_drops_the_connection_record() {
let mut h = Harness::new();
+249 -28
View File
@@ -1,41 +1,262 @@
use jzon::{object};
use actix_web::{web, Responder};
use crate::router::{userdata, databases, Session, Api};
use jzon::{array, object, JsonValue};
use actix_web::{web, HttpRequest};
use crate::router::{chat, global, items, userdata, databases, Session, Api};
use databases::csv::{table, Region};
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.route("/story/read", web::post().to(read));
}
async fn read(Session { key, body }: Session) -> impl Responder {
let mut user = userdata::get_acc(&key);
let part = body["master_story_part_id"].as_i64().unwrap();
let master_id = databases::STORY[part.to_string()]["masterStoryId"].as_i64().unwrap();
static READ_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let index = user["story_list"].members().position(|r| r["master_story_id"] == master_id);
fn has_read(user: &JsonValue, story: i64, part: i64) -> bool {
user["story_list"].members().any(|s| s["master_story_id"] == story
&& s["master_story_part_ids"].contains(part))
}
if index.is_none() {
user["story_list"].push(object!{
master_story_id: master_id,
master_story_part_ids: []
}).unwrap();
}
for story in user["story_list"].members_mut() {
if story["master_story_id"] == master_id && !story["master_story_part_ids"].contains(part) {
story["master_story_part_ids"].push(part).unwrap();
pub fn refresh(user: &JsonValue, missions: &mut JsonValue) {
let mut counts = std::collections::HashMap::<i64, i64>::new();
for live in user["live_list"].members() {
let song = &databases::MUSIC[live["master_live_id"].to_string()];
if !song.is_null() {
*counts.entry(song["bandCategory"].as_i64().unwrap_or(0)).or_default()
+= live["clear_count"].as_i64().unwrap_or(0);
}
}
for (_, master) in databases::MISSION_LIST.entries() {
let condition = master["conditionType"].as_i64().unwrap_or(0);
if !matches!(condition, 73 | 78) { continue; }
let id = master["id"].as_i64().unwrap();
let value = master["conditionValues"][0].as_i64().unwrap_or(0);
let count = if condition == 73 { counts.get(&value).copied().unwrap_or(0) }
else { super::event::get_points(value as u32, user) };
if condition == 78 && count == 0 { continue; }
set_progress(id, count, missions);
}
}
userdata::save_acc(&key, user.clone());
fn set_progress(id: i64, count: i64, missions: &mut JsonValue) {
if !missions.members().any(|m| m["master_mission_id"] == id) {
missions.push(object! {master_mission_id: id, status: 1, progress: 0, expire_date_time: 0, not_visible: 0}).unwrap();
}
let master = &databases::MISSION_LIST[id.to_string()];
let target = master["conditionNumber"].as_i64().unwrap();
let mission = missions.members_mut().find(|m| m["master_mission_id"] == id).unwrap();
if mission["status"].as_i64().unwrap_or(1) >= 2 { return; }
let progress = count.max(mission["progress"].as_i64().unwrap_or(0)).min(target);
mission["progress"] = progress.into();
mission["status"] = if progress >= target { 2 } else { 1 }.into();
}
pub fn visit_event(key: &str, event: u32) {
let user = userdata::get_acc(key);
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
if !super::event::is_in_session(event, now) { return; }
let mut missions = userdata::get_acc_missions(key);
for (_, master) in databases::MISSION_LIST.entries() {
if master["conditionType"] == 79 && master["conditionValues"][0] == event {
set_progress(master["id"].as_i64().unwrap(), 1, &mut missions);
}
}
refresh(&user, &mut missions);
userdata::save_acc_missions(key, missions);
}
Api(Some(object!{
"gift_list":[],
"updated_value_list":{
"story_list": user["story_list"].clone()
},
"reward_list":[],
"clear_mission_ids":[]
}))
async fn read(req: HttpRequest, Session { key, body }: Session) -> Api {
let _read = crate::lock_onto_mutex!(READ_LOCK);
let Some(part_id) = body["master_story_part_id"].as_i64() else { return Api(None); };
let part = &databases::STORY[part_id.to_string()];
let Some(story_id) = part["masterStoryId"].as_i64() else { return Api(None); };
let mut user = userdata::get_acc(&key);
let empty = || object! {gift_list: [], updated_value_list: [], reward_list: [], clear_mission_ids: []};
if has_read(&user, story_id, part_id) { return Api(Some(empty())); }
if let Some(previous) = databases::STORY.entries().map(|(_, p)| p)
.filter(|p| p["masterStoryId"] == story_id && p["number"].as_i64() < part["number"].as_i64())
.max_by_key(|p| p["number"].as_i64()) {
if !has_read(&user, story_id, previous["id"].as_i64().unwrap()) { return Api(None); }
}
let mut missions = userdata::get_acc_missions(&key);
refresh(&user, &mut missions);
let releases = table(Region::Jp, "story_release");
let now = global::set_time(global::timestamp(), user["user"]["id"].as_i64().unwrap(), true);
let stories = table(Region::Jp, "story");
let Some(story_master) = stories.members().find(|s| s["id"] == story_id) else { return Api(None); };
let mut fully_open = false;
if let Some(release) = releases.members().find(|r| r["masterStoryPartId"] == part_id) {
fully_open = global::parse_datetime(release["openedAtAfterEvent"].as_str().unwrap_or(""))
.is_some_and(|at| now >= at);
if story_master["type"] == 3 {
let event_stories = table(Region::Jp, "event_story");
let ended = event_stories.members().find(|e| e["masterStoryId"] == story_id)
.and_then(|e| {
let event = &databases::EVENTS[e["masterEventId"].to_string()];
global::parse_datetime(databases::RELEASE_LABEL[event["masterReleaseLabelId"].to_string()]["closedAt"].as_str().unwrap_or(""))
}).is_some_and(|end| now > end);
fully_open &= ended;
}
let mission = release["masterMissionId"].as_i64().unwrap_or(0);
if !fully_open {
let rank = items::get_user_rank_data(user["user"]["exp"].as_i64().unwrap_or(0))["rank"].as_i64().unwrap_or(0);
if rank < release["userRank"].as_i64().unwrap_or(0) { return Api(None); }
let live = release["masterLiveId"].as_i64().unwrap_or(0);
let score = release["liveScore"].as_i64().unwrap_or(0);
if live != 0 && score != 0 && !user["live_list"].members().any(|l|
l["master_live_id"] == live && l["clear_count"].as_i64().unwrap_or(0) > 0
&& l["high_score"].as_i64().unwrap_or(0) >= score) { return Api(None); }
}
if !fully_open && mission != 0 && !missions.members().any(|m|
m["master_mission_id"] == mission && m["status"].as_i64().unwrap_or(0) >= 2) {
return Api(None);
}
}
if !fully_open && (!super::event::release_label_is_open(part["masterReleaseLabelId"].as_i64().unwrap_or(0), now)
|| !super::event::release_label_is_open(story_master["masterReleaseLabelId"].as_i64().unwrap_or(0), now)) {
return Api(None);
}
let mut home = userdata::get_acc_home(&key);
let mut chats = userdata::get_acc_chats(&key);
let mut response = empty();
let mut rooms = array![];
let mut chapters = array![];
let region = if items::get_region(req.headers()) { Region::Jp } else { Region::En };
let mut rewards: Vec<_> = table(region, "story_reward").members()
.filter(|r| r["id"] == part["masterStoryRewardId"]).cloned().collect();
// Official replies group room rewards before chapter rewards.
rewards.sort_by_key(|r| (r["type"].as_i64(), r["number"].as_i64()));
for reward in rewards {
let value = reward["value"].as_i64().unwrap();
match reward["type"].as_i64().unwrap() {
1 => {
if home["home"]["gift_list"].len() >= items::GIFT_LIMIT { return Api(None); }
let mut gift = reward.clone();
let next_id = loop {
let id = rand::random::<u64>() & ((1u64 << 53) - 1);
if id != 0 && !home["home"]["gift_list"].members().any(|g| g["id"] == id) { break id; }
};
gift["id"] = next_id.into();
let reason = if region == Region::Jp { "スクールアイドルの日常の報酬です。" } else { "Daily Life of School idols reward." };
let gift = items::gift_item(&gift, reason, &mut home);
response["gift_list"].push(gift).unwrap();
}
16 => {} // Chapter rewards below materialize room ownership.
17 => {
if !chat::add_chat_from_chapter_id(value, &mut chats) { continue; }
chapters.push(value).unwrap();
let chapter = &databases::CHAPTERS_MASTER[value.to_string()];
let room = databases::CHATS[chapter["masterChatId"].to_string()][chapter["roomId"].to_string()]["id"].clone();
rooms.push(room).unwrap();
}
_ => return Api(None),
}
response["reward_list"].push(object! {
type: reward["type"].clone(), value: value,
level: reward["level"].clone(), amount: reward["amount"].clone()
}).unwrap();
}
if !chapters.is_empty() {
response["updated_value_list"] = object! {master_chat_room_ids: rooms, master_chat_chapter_ids: chapters};
}
if !user["story_list"].members().any(|s| s["master_story_id"] == story_id) {
user["story_list"].push(object! {master_story_id: story_id, master_story_part_ids: []}).unwrap();
}
for story in user["story_list"].members_mut().filter(|s| s["master_story_id"] == story_id) {
story["master_story_part_ids"].push(part_id).unwrap();
}
userdata::save_acc_home(&key, home);
userdata::save_acc_chats(&key, chats);
userdata::save_acc_missions(&key, missions);
userdata::save_acc(&key, user);
Api(Some(response))
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn event_entry_and_point_thresholds_unlock_story_in_its_original_period() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Event story test").unwrap();
userdata::save_server_data(&key, object! {
server_time: global::parse_datetime("2023/04/22 12:00:00").unwrap(), server_time_set: global::timestamp()
});
let request = || actix_web::test::TestRequest::default().to_http_request();
let part = |id: i64| Session {key: key.clone(), body: object! {master_story_part_id: id}};
assert!(read(request(), part(40001001)).await.0.is_none());
visit_event(&key, 101);
assert!(read(request(), part(40001001)).await.0.is_some());
let mut user = userdata::get_acc(&key);
super::super::event::give_event_points(101, 4999, &mut user);
userdata::save_acc(&key, user);
assert!(read(request(), part(40001002)).await.0.is_none());
let mut user = userdata::get_acc(&key);
super::super::event::give_event_points(101, 1, &mut user);
userdata::save_acc(&key, user);
assert!(read(request(), part(40001002)).await.0.is_some());
assert!(read(request(), part(40001003)).await.0.is_none());
}
#[actix_web::test]
async fn story_unlocks_before_and_after_full_release() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Story unlock test").unwrap();
let request = || actix_web::test::TestRequest::default().to_http_request();
let read_part = |id: i64| Session {key: key.clone(), body: object! {master_story_part_id: id}};
userdata::save_server_data(&key, object! {
server_time: global::parse_datetime("2023/12/01 12:00:00").unwrap(), server_time_set: global::timestamp()
});
assert!(read(request(), read_part(210001001)).await.0.is_none());
let mut user = userdata::get_acc(&key);
user["live_list"] = array![object! {master_live_id: 1100101, clear_count: 1}];
userdata::save_acc(&key, user);
assert!(read(request(), read_part(210001001)).await.0.is_some());
assert!(read(request(), read_part(210001002)).await.0.is_none());
let mut user = userdata::get_acc(&key);
user["live_list"][0]["clear_count"] = 10.into();
userdata::save_acc(&key, user);
assert!(read(request(), read_part(210001002)).await.0.is_some());
// Event parts remain gated before full release, including previous-part order.
assert!(read(request(), read_part(40001001)).await.0.is_none());
userdata::save_server_data(&key, object! {
server_time: global::parse_datetime("2024/03/01 12:00:00").unwrap(), server_time_set: global::timestamp()
});
assert!(read(request(), read_part(40001002)).await.0.is_none());
assert!(read(request(), read_part(40001001)).await.0.is_some());
assert!(read(request(), read_part(40001002)).await.0.is_some());
}
#[actix_web::test]
async fn story_rewards_persist_and_replays_do_not_pay_again() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Story test").unwrap();
userdata::save_acc_chats(&key, array![]);
let request = || actix_web::test::TestRequest::default().to_http_request();
let reply = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 20000001}}).await.0.unwrap();
// jp/logs.txt:1871-1902, the first Aqours introduction read.
assert_eq!(reply["updated_value_list"], object! {
master_chat_room_ids: [2001001, 2101001], master_chat_chapter_ids: [200100101, 210100101]
});
assert_eq!(reply["reward_list"].len(), 4);
assert_eq!(reply["reward_list"][0]["type"], 16);
assert_eq!(userdata::get_acc_chats(&key).len(), 2);
assert!(has_read(&userdata::get_acc(&key), 129999, 20000001));
let repeat = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 20000001}}).await.0.unwrap();
assert!(repeat["reward_list"].is_empty());
// jp/logs.txt:18507-18538: daily-life gems go to gifts, not straight to the wallet.
let mut user = userdata::get_acc(&key);
user["live_list"] = array![object! {master_live_id: 1100101, clear_count: 1}];
userdata::save_acc(&key, user);
let gems = userdata::get_acc(&key)["gem"].clone();
let before = userdata::get_acc_home(&key)["home"]["gift_list"].len();
let first = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 210001001}}).await.0.unwrap();
assert_eq!(first["gift_list"].len(), 1);
assert_eq!(first["gift_list"][0]["amount"], 100);
assert_eq!(userdata::get_acc_home(&key)["home"]["gift_list"].len(), before + 1);
assert_eq!(userdata::get_acc(&key)["gem"], gems);
let repeat = read(request(), Session {key: key.clone(), body: object! {master_story_part_id: 210001001}}).await.0.unwrap();
assert!(repeat["gift_list"].is_empty());
assert_eq!(userdata::get_acc_home(&key)["home"]["gift_list"].len(), before + 1);
assert!(read(request(), Session {key, body: object! {master_story_part_id: -1}}).await.0.is_none());
}
}
+70 -15
View File
@@ -71,8 +71,6 @@ pub enum UserView {
const DEFAULT_CARD: i64 = 10010001;
lazy_static! {
// Each character's lowest official card id: the stand-in shown to viewers
// who can't resolve a custom card of that character
static ref OFFICIAL_CARD_BY_CHARACTER: HashMap<i64, i64> = {
let mut rv: HashMap<i64, i64> = HashMap::new();
for entry in databases::CARD_LIST.entries() {
@@ -90,8 +88,6 @@ lazy_static! {
};
}
// The character behind any card id: baked masterdata first, then the runtime
// custom-card db, then the imported band's id arithmetic (prefix - 9000)
fn card_character(id: i64) -> Option<i64> {
let card = &databases::CARD_LIST[id.to_string()];
if !card.is_empty() {
@@ -103,9 +99,6 @@ fn card_character(id: i64) -> Option<i64> {
Some(id / 10000 - 9000)
}
// A custom card the viewer can't resolve shows as its character's base
// official card - the right face, never a crash. Characters with no official
// card (custom ones included) fall back to the default
fn proxy_card_id(id: i64) -> i64 {
if !card::is_custom(id) {
return id;
@@ -119,6 +112,28 @@ fn proxy_card_id(id: i64) -> i64 {
*rv
}
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: []
}
}
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) {
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);
@@ -176,12 +191,22 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) ->
let mut rv = object!{
user: user["user"].clone(),
favorite_card: global::get_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_cool_card: global::get_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)
favorite_card: slot_card(user["user"]["favorite_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: slot_card(user["user"]["guest_cool_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)
};
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 {
rv["main_deck_detail"] = object!{
total_power: 0,
@@ -223,6 +248,9 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) ->
rv
}
// here are some tests that ai wrote...
#[cfg(test)]
mod tests {
use super::*;
@@ -237,6 +265,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
// single default (the old prefix arithmetic collapsed 14000+ to Honoka)
#[test]
@@ -254,11 +309,11 @@ mod tests {
// A runtime card on an official character proxies to that character
let id = db::next_card_id();
db::insert_card(id, 2003, 5101, &jzon::object!{ "master_card_id": id, "rarity": 1 }, true, false);
db::insert_card(id, 2003, 5101, &jzon::object!{ "master_card_id": id, "rarity": 1 }, true, false).unwrap();
assert_eq!(proxy_card_id(id), 20030001);
// On a custom character (no official card) it falls to the default
let orphan = db::next_card_id();
db::insert_card(orphan, db::FIRST_CHARACTER_ID, 5101, &jzon::object!{ "master_card_id": orphan, "rarity": 1 }, true, false);
db::insert_card(orphan, db::FIRST_CHARACTER_ID, 5101, &jzon::object!{ "master_card_id": orphan, "rarity": 1 }, true, false).unwrap();
assert_eq!(proxy_card_id(orphan), DEFAULT_CARD);
// A deleted/unknown runtime id can't resolve a character either
let unknown = db::next_card_id() + 5000;
@@ -281,9 +336,9 @@ mod tests {
wipe(5102);
let published = db::next_card_id();
db::insert_card(published, 2003, 5102, &jzon::object!{ "master_card_id": published, "rarity": 1 }, true, false);
db::insert_card(published, 2003, 5102, &jzon::object!{ "master_card_id": published, "rarity": 1 }, true, false).unwrap();
let draft = db::next_card_id();
db::insert_card(draft, 2003, 5102, &jzon::object!{ "master_card_id": draft, "rarity": 1 }, false, false);
db::insert_card(draft, 2003, 5102, &jzon::object!{ "master_card_id": draft, "rarity": 1 }, false, false).unwrap();
assert!(custom_card::viewer_can_resolve(published, custom_card::PROTOCOL_VERSION));
assert!(!custom_card::viewer_can_resolve(draft, custom_card::PROTOCOL_VERSION));
+98 -7
View File
@@ -17,6 +17,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
.route("/migration", web::post().to(migration))
.route("/gglrequestmigrationcode", web::post().to(request_migration_code))
.route("/gglverifymigrationcode", web::post().to(verify_migration_code))
.route("/migration/card/list", web::post().to(migration_card_list))
.route("/migration/card/link", web::post().to(migration_card_link))
.route("/migration/card/unlink", web::post().to(migration_card_unlink))
.route("/getregisteredplatformlist", web::post().to(getregisteredplatformlist))
.route("/sif/migrate", web::post().to(sif_migrate))
.route("/ss/migrate", web::post().to(sifas_migrate))
@@ -45,6 +48,9 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
}
}
userdata::save_acc(&key, user.clone());
let mut missions = userdata::get_acc_missions(&key);
let cleared = super::beginner_mission::refresh(&user, &mut missions);
userdata::save_acc_missions(&key, missions);
Api(Some(object!{
"deck": {
@@ -52,7 +58,7 @@ async fn deck(Session { key, body }: Session) -> impl Responder {
"leader_role": 0,
"main_card_ids": body["main_card_ids"].clone()
},
"clear_mission_ids": []
"clear_mission_ids": cleared
}))
}
@@ -67,8 +73,7 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder {
}
}
// Runtime custom cards are unresolvable below protocol 3. start.rs blocks
// flagged accounts on old clients, so this is belt-and-braces
// Don't allow account downgrade (will crash client)
if !crate::router::custom_card::client_supports(&req) {
crate::router::custom_card::strip_unsupported(&mut user);
}
@@ -168,7 +173,11 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
user["user"]["master_title_ids"][0] = body["master_title_ids"][0].clone();
}
if !body["birthday"].is_null() {
user["user"]["birthday"][0] = body["birthday"].clone();
let Some(birthday) = body["birthday"].as_str() else { return Api(None); };
if !valid_birthday(birthday) { return Api(None); }
let previous = user["user"]["birthday"].as_str().unwrap_or("");
if valid_birthday(previous) && previous != birthday { return Api(None); }
user["user"]["birthday"] = birthday.into();
}
userdata::save_acc(&key, user.clone());
@@ -179,11 +188,46 @@ async fn user_post(Session { key, body }: Session) -> impl Responder {
}))
}
pub(super) fn valid_birthday(value: &str) -> bool {
if value.len() != 4 || !value.bytes().all(|b| b.is_ascii_digit()) { return false; }
let month: usize = value[..2].parse().unwrap();
let day: u8 = value[2..].parse().unwrap();
let days = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
(1..=12).contains(&month) && day > 0 && day <= days[month - 1]
}
#[cfg(test)]
mod birthday_tests {
use super::*;
#[actix_web::test]
async fn birthday_update_round_trips_as_mmdd() {
let _test = crate::runtime::lock_test_data_path();
let (_, key) = userdata::starter::create("Birthday test").unwrap();
// jp/logs.txt:157322-157353 stores this as a string, not an array.
user_post(Session {key: key.clone(), body: object! {birthday: "0331"}}).await;
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
user_post(Session {key: key.clone(), body: object! {birthday: "0230"}}).await;
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
user_post(Session {key: key.clone(), body: object! {birthday: "0401"}}).await;
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
let mut legacy = userdata::get_acc(&key);
legacy["user"]["birthday"] = array!["0331"];
userdata::save_acc(&key, legacy);
assert_eq!(userdata::get_acc(&key)["user"]["birthday"], "0331");
assert!(valid_birthday("0229"));
for invalid in ["", "0001", "1301", "0100", "0431", "abcd", "2024"] {
assert!(!valid_birthday(invalid));
}
}
}
pub async fn announcement(Login(key): Login) -> impl Responder {
let mut user = userdata::get_acc_home(&key);
user["home"]["new_announcement_flag"] = (0).into();
user["home"]["announcement_seen_at"] = (global::timestamp() as i64).into();
userdata::save_acc_home(&key, user);
@@ -192,9 +236,11 @@ pub async fn announcement(Login(key): Login) -> impl Responder {
}))
}
async fn get_migration_code(Body(body): Body) -> impl Responder {
let Some(user_id) = body["user_id"].as_i64() else { return Api(None); };
async fn get_migration_code(Session { key, .. }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
let code = userdata::user::migration::get_acc_token(user_id);
Api(Some(object!{
@@ -207,6 +253,11 @@ async fn register_password(Session { key, body }: Session) -> impl Responder {
let user = userdata::get_acc(&key);
userdata::user::migration::save_acc_transfer(user["user"]["id"].as_i64().unwrap(), &body["pass"].to_string());
if !body["pass"].as_str().unwrap_or("").is_empty() {
let mut missions = userdata::get_acc_missions(&key);
super::beginner_mission::advance(25, None, 1, &mut missions);
userdata::save_acc_missions(&key, missions);
}
Api(Some(array![]))
}
@@ -240,6 +291,46 @@ async fn request_migration_code(Body(body): Body) -> impl Responder {
"twxuid": user["login_token"].to_string()
}))
}
async fn migration_card_list(Session { key, .. }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
Api(Some(object!{
"card_ids": userdata::user::migration::cards_of_account(user_id)
}))
}
async fn migration_card_link(Session { key, body }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
match userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), user_id) {
Ok((card_id, rebound)) => Api(Some(object!{
"card_id": card_id,
"rebound": rebound
})),
Err(_) => Api(None)
}
}
async fn migration_card_unlink(Session { key, body }: Session) -> impl Responder {
let user_id = userdata::uid_from_login_token(&key);
if user_id == 0 {
return Api(None);
}
let Some(card_id) = userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else {
return Api(None);
};
if !userdata::user::migration::remove_card_of(&card_id, user_id) {
return Api(None);
}
Api(Some(object!{
"card_id": card_id
}))
}
async fn migration(Body(body): Body) -> impl Responder {
let user = userdata::get_name_and_rank(body["user_id"].to_string().parse::<i64>().unwrap());
+50 -106
View File
@@ -1,4 +1,5 @@
pub mod user;
pub mod starter;
use rusqlite::params;
use lazy_static::lazy_static;
@@ -96,54 +97,6 @@ INSERT OR IGNORE INTO exchange (user_id, exchange) SELECT user_id, '[]' FROM use
}
}
// maybe we will use this later
/*
pub fn downgrade_account_cards(user: &JsonValue) -> JsonValue {
let mut rv = user.clone();
let mut cards = array![];
let mut ids = array![];
for data in user["card_list"].members() {
let id = data["master_card_id"].as_i64().unwrap_or(0);
let downgraded = guest::proxy_card_id(id);
// Whole characters share one downgrade, and the client can't hold the
// same card twice
if ids.contains(downgraded) {
continue;
}
ids.push(downgraded).unwrap();
let mut data = data.clone();
if downgraded != id {
data["id"] = downgraded.into();
data["master_card_id"] = downgraded.into();
}
cards.push(data).unwrap();
}
rv["card_list"] = cards;
for deck in rv["deck_list"].members_mut() {
let mut used = array![];
for slot in deck["main_card_ids"].members_mut() {
let id = guest::proxy_card_id(slot.as_i64().unwrap_or(0));
// Cards the downgrade merged away leave the slot empty
if id == 0 || used.contains(id) {
*slot = (0).into();
continue;
}
used.push(id).unwrap();
*slot = id.into();
}
}
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
let id = rv["user"][key].as_i64().unwrap_or(0);
rv["user"][key] = guest::proxy_card_id(id).into();
}
rv
}
*/
fn acc_exists(uid: i64) -> bool {
DATABASE.lock_and_select("SELECT user_id FROM userdata WHERE user_id=?1", params!(uid)).is_ok()
}
@@ -246,9 +199,6 @@ fn get_uid(token: &str) -> i64 {
data.parse::<i64>().unwrap_or(0)
}
// The account a login token belongs to, 0 when the token is unknown. The HTTP layer
// never needs this (it keys everything off the token itself), but the multi-live relay
// authenticates a {userId, token} pair and has to check the two agree.
pub fn uid_from_login_token(token: &str) -> i64 {
get_uid(token)
}
@@ -300,12 +250,7 @@ fn get_data(auth_key: &str, row: &str) -> JsonValue {
jzon::parse(&result.unwrap()).unwrap()
}
// Deleted custom songs leave stale score/clear records behind. They're wiped
// lazily when the userdata is pulled: collect the user's own music-id-keyed
// rows in the custom range (official ids are never candidates) and drop the
// ones whose id no longer exists in the catalog. Custom ids are never reused,
// so the wipe is final. A song that still exists but isn't visible to this
// user is NOT wiped - existence is what's checked, not visibility
// Prune deleted custom songs
fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
// Feature off: never touch custom_songs.db, leave userdata untouched
if crate::router::custom_song::disabled() {
@@ -323,7 +268,9 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
if candidates.is_empty() {
return false;
}
let dead = custom_song::dead_music_ids(&candidates);
let Some(dead) = custom_song::dead_music_ids(&candidates) else {
return false;
};
if dead.is_empty() {
return false;
}
@@ -340,13 +287,7 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
true
}
// Deleted custom cards leave stale card_list rows behind - and a card_list id
// the client can't resolve aborts its whole login. Wiped lazily when the
// userdata is pulled, mirroring remove_deleted_custom_songs: only the runtime
// band is a candidate (official/imported ids never are), ids are never
// reused, so the wipe is final. A card that still exists but is unpublished
// is NOT wiped - existence is what's checked, and the catalog keeps serving
// owned ids (custom_card::owned_runtime_ids) so holders still resolve them
// Prune deleted custom cards
fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
// Feature off: never touch custom_cards.db, leave userdata untouched
if crate::router::custom_card::disabled() {
@@ -362,7 +303,9 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
if candidates.is_empty() {
return false;
}
let dead = custom_card::dead_card_ids(&candidates);
let Some(dead) = custom_card::dead_card_ids(&candidates) else {
return false;
};
if dead.is_empty() {
return false;
}
@@ -381,9 +324,6 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
}
}
}
// A dead favorite/guest card repoints to the account's first remaining
// card (every account has its tutorial cards; the fallback can't trigger
// in practice)
let fallback = user["card_list"][0]["master_card_id"].as_i64().unwrap_or(10010001);
for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] {
if dead.contains(user["user"][key].as_i64().unwrap_or(0)) {
@@ -397,6 +337,12 @@ pub fn get_acc(auth_key: &str) -> JsonValue {
let mut user = get_data(auth_key, "userdata");
cleanup_account(&mut user);
let mut changed = remove_deleted_custom_songs(&mut user);
// Repair the old /user writer's ["MMDD"] shape without losing the chosen birthday.
if user["user"]["birthday"].is_array() {
let birthday = user["user"]["birthday"][0].as_str().unwrap_or("").to_owned();
user["user"]["birthday"] = if super::user::valid_birthday(&birthday) { birthday } else { String::new() }.into();
changed = true;
}
if remove_deleted_custom_cards(&mut user) {
changed = true;
}
@@ -417,7 +363,9 @@ pub fn get_acc_home(auth_key: &str) -> JsonValue {
user
}
pub fn get_acc_missions(auth_key: &str) -> JsonValue {
get_data(auth_key, "missions")
let mut missions = get_data(auth_key, "missions");
super::beginner_mission::ensure(&mut missions);
missions
}
pub fn get_acc_loginbonus(auth_key: &str) -> JsonValue {
get_data(auth_key, "loginbonus")
@@ -483,22 +431,6 @@ pub fn save_server_data(auth_key: &str, data: JsonValue) {
save_data(auth_key, "server_data", data);
}
// Read-modify-write of one account's server_data as ONE atomic step.
//
// get_server_data + save_server_data open a connection each, so two requests for the same
// account both read the pre-state and the later write wins - which for a record that is
// meant to be spent exactly once (the started-live record /multi_live/end awards off) means
// both ends see it and both award. Everything here happens inside a single BEGIN IMMEDIATE
// transaction instead, so concurrent callers serialise and the second one observes what the
// first one wrote.
//
// `f` sees the parsed server_data and returns whatever the caller needs out of it; the
// (possibly mutated) value is written back before the transaction commits. get_key runs
// BEFORE the transaction because it can create the account, which writes on its own
// connection and would otherwise deadlock against our write lock.
//
// A database error yields T::default() rather than a panic: the callers all have a
// "nothing to spend" branch, which is the right answer when the record could not be read.
pub fn modify_server_data<T: Default>(auth_key: &str, f: impl FnOnce(&mut JsonValue) -> T) -> T {
let key = get_key(auth_key);
let rv = DATABASE.lock_and_transact(|conn| {
@@ -787,6 +719,29 @@ pub fn export_user(token: &str) -> Option<JsonValue> {
})
}
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));
}
crate::router::custom_song::purge_owner(user_id);
crate::router::custom_card::purge_owner(user_id);
crate::router::custom_3dmv::purge_owner(user_id);
}
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 {
// 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("
@@ -802,27 +757,16 @@ pub fn purge_accounts() -> usize {
for uid in dead_uids.members() {
let user_id = uid.as_i64().unwrap();
println!("Removing dead UID: {}", user_id);
crate::database::gree::delete_uuid(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));
delete_account(user_id);
}
DATABASE.lock_and_exec("VACUUM", params!());
crate::database::gree::setup();
dead_uids.len()
}
// more tests???
#[cfg(test)]
mod tests {
use super::*;
@@ -837,10 +781,10 @@ mod tests {
let mut user = get_acc(token);
let deleted_id = custom_song::next_music_id();
custom_song::insert_song(deleted_id, 1, &object!{music_id: deleted_id}, "public", &array![], false);
custom_song::insert_song(deleted_id, 1, &object!{music_id: deleted_id}, "public", &array![], false).unwrap();
// Exists but isn't visible to this user - must survive the wipe
let private_id = custom_song::next_music_id();
custom_song::insert_song(private_id, 1, &object!{music_id: private_id}, "private", &array![], false);
custom_song::insert_song(private_id, 1, &object!{music_id: private_id}, "private", &array![], false).unwrap();
// 1100101 is a real stock live-id shape (7-digit, >= FIRST_MUSIC_ID): it
// must survive the custom-song wipe, unlike an unrealistic sub-10000 id
@@ -865,7 +809,7 @@ mod tests {
assert_eq!(user["live_list"].len(), 3);
assert_eq!(user["live_mission_list"].len(), 3);
custom_song::delete_song(deleted_id);
custom_song::delete_song(deleted_id).unwrap();
// The next pull drops the dead id's records and only those
let user = get_acc(token);
@@ -894,10 +838,10 @@ mod tests {
let mut user = get_acc(token);
let deleted_id = custom_card::next_card_id();
custom_card::insert_card(deleted_id, 1001, 1, &jzon::object!{ "master_card_id": deleted_id, "rarity": 1 }, true, true);
custom_card::insert_card(deleted_id, 1001, 1, &jzon::object!{ "master_card_id": deleted_id, "rarity": 1 }, true, true).unwrap();
// Exists but was unpublished - must survive the wipe
let unpublished_id = custom_card::next_card_id();
custom_card::insert_card(unpublished_id, 1001, 1, &jzon::object!{ "master_card_id": unpublished_id, "rarity": 1 }, false, false);
custom_card::insert_card(unpublished_id, 1001, 1, &jzon::object!{ "master_card_id": unpublished_id, "rarity": 1 }, false, false).unwrap();
for id in [deleted_id, unpublished_id] {
user["card_list"].push(jzon::object!{
+465
View File
@@ -0,0 +1,465 @@
// Accounts that are born finished.
//
// Everything below Title in the client assumes a tutorial-complete account: a
// name, a favourite card, a nine-card deck, tutorial_step 130. The tutorial that
// produces one is four client requests, and nothing on the server can create
// that state on its own - which is exactly what the arcade needs, twice per
// cabinet (the machine identity and its guest) and again at every credit (the
// guest, rewritten in place).
//
// So this module replays those four requests' mutations, in their order, off
// their own handlers' code:
//
// POST /api/lottery the tutorial draw leaves a card in card_list,
// which is what /user/initialize reads as the
// account's "ur" (lottery.rs:342-358, user.rs:383)
// POST /api/user/initialize favourite + guest cards, 3000 gems, the band
// title, the nine base cards, deck slot 1,
// character_list, the first bond mission
// (user.rs:371-453)
// POST /api/user the player's name (user.rs:138-140)
// POST /api/tutorial tutorial_step 130 and full stamina
// (tutorial.rs:13-17)
//
// The one deliberate difference is the gacha: a cabinet draws nothing random.
// The chosen member's own base card takes the "ur" slot instead, which leaves
// the starter deck complete (nine distinct cards) rather than one short, and
// makes every arcade account byte-identical apart from its name.
//
// `write_starter_rows` is the single source of truth for what an account is made
// of, shared by creation and by the guest reset - the two can never drift.
//
// It is also the single source of truth for what an account is NOT made of. A
// guest is handed to a stranger every credit, so the reset has to leave behind
// exactly what a freshly created account has: the eleven data rows, one login
// token, and nothing else. Every other row `delete_account` recognises as
// account-owned (mod.rs:791) is deleted here rather than rewritten - the
// transfer code and password /api/user/registerpassword registers, the webui
// session, the gree device certificate - because each of them is a credential
// the previous player could keep and come back with. The login token is the one
// thing that is kept in the sense that the account keeps having one, but it is
// re-drawn too: the old one is on the cabinet's disk and may be on the previous
// player's phone, and /api/arcade/session hands the new one straight back to the
// cabinet in the `uuid` the client already adopts.
use jzon::{array, object, JsonValue};
use rusqlite::params;
use crate::include_file;
use crate::router::{card, chat, global, items, live};
use super::{DATABASE, NEW_USER, acc_exists, generate_uid};
// The member every arcade account is built around: Kousaka Honoka, the first
// member of the first band, so the deck is deterministic and recognisable.
// user/initialize derives everything else from this one id.
const STARTER_CHARACTER_ID: i64 = 1001;
// The nine cards /user/initialize rewards for a mu's pick (user.rs:398)
const STARTER_CARDS: &[i64] = &[
10010001, 10020001, 10030001, 10040001, 10050001,
10060001, 10070001, 10080001, 10090001
];
// The card the tutorial gacha would have left in card_list[0]. See the module
// note: the chosen member's own base card, so the deck comes out whole.
const STARTER_UR: i64 = 10010001;
// user.rs:396-411: 3000000 + the band offset (0 for mu's) + the member's index
// within the band, which is the last two digits of the character id
const STARTER_TITLE_ID: i64 = 3_000_000 + STARTER_CHARACTER_ID % 100;
// Every account name is stored verbatim by /api/user, so a cabinet name is no
// more dangerous than a player name - but it arrives from a machine that types
// it once and never again, so it is clamped rather than trusted to be sane.
pub const MAX_NAME_LEN: usize = 32;
pub fn clean_name(name: &str, fallback: &str) -> String {
let name: String = name
.chars()
.filter(|c| !c.is_control())
.take(MAX_NAME_LEN)
.collect();
let name = name.trim();
if name.is_empty() {
return fallback.to_string();
}
name.to_string()
}
// The userdata blob a finished tutorial leaves behind, plus the two side rows it
// writes on the way (the mission progress and the chat it unlocks).
fn tutorial_complete(uid: i64, name: &str) -> (JsonValue, JsonValue, JsonValue, JsonValue) {
let now = global::timestamp();
// create_acc (mod.rs:230-232)
let mut user = NEW_USER.clone();
user["user"]["id"] = uid.into();
user["stamina"]["last_updated_time"] = now.into();
let mut home = jzon::parse(&include_file!("src/router/userdata/new_user_home.json")).unwrap();
let mut missions = jzon::parse(&include_file!("src/router/userdata/missions.json")).unwrap();
let mut chats = array![];
// --- POST /api/user/initialize (user.rs:371-453) ------------------------
chat::add_chat(STARTER_CHARACTER_ID, 1, &mut chats);
user["user"]["favorite_master_card_id"] = STARTER_UR.into();
user["user"]["guest_smile_master_card_id"] = STARTER_UR.into();
user["user"]["guest_cool_master_card_id"] = STARTER_UR.into();
user["user"]["guest_pure_master_card_id"] = STARTER_UR.into();
home["home"]["preset_setting"][0]["illust_master_card_id"] = STARTER_UR.into();
user["gem"]["free"] = (3000).into();
user["gem"]["total"] = (3000).into();
user["user"]["master_title_ids"][0] = STARTER_TITLE_ID.into();
// The clear-mission and chat out-parameters are thrown away here exactly as
// /user/initialize throws them away (user.rs:418): the only chat a fresh
// account keeps is the one added above.
for id in STARTER_CARDS {
items::give_character(*id, &mut user, &mut missions, &mut array![], &mut array![]);
}
let mut others = array![];
for id in STARTER_CARDS {
if id / 10000 != STARTER_CHARACTER_ID {
others.push(*id).unwrap();
}
}
for slot in 0..9 {
let card_id = if slot == 4 {
STARTER_UR
} else if slot < 4 {
others[slot].as_i64().unwrap_or(0)
} else {
others[slot - 1].as_i64().unwrap_or(0)
};
user["deck_list"][0]["main_card_ids"][slot] = card_id.into();
}
user["character_list"] = array![object!{
master_character_id: STARTER_CHARACTER_ID,
exp: 1
}];
let bond = live::bond_missions(STARTER_CHARACTER_ID);
if !bond.is_empty() {
items::advance_mission(bond[0][1].as_i64().unwrap(), 1, bond[0][0].as_i64().unwrap(), &mut missions);
}
// --- POST /api/user (user.rs:138-140) -----------------------------------
user["user"]["name"] = name.into();
// --- POST /api/tutorial, the final step (tutorial.rs:13-17) -------------
user["tutorial_step"] = (130).into();
user["stamina"]["stamina"] = (100).into();
user["stamina"]["last_updated_time"] = now.into();
(user, home, missions, chats)
}
// Everything create_acc seeds, as (table, value). The column always carries the
// table's own name; `userdata` is the one row with extra columns and is written
// by the caller.
fn side_rows(chats: &JsonValue, home: &JsonValue, missions: &JsonValue) -> Vec<(&'static str, String)> {
let now = global::timestamp();
vec![
("userhome", jzon::stringify(home.clone())),
("missions", jzon::stringify(missions.clone())),
("chats", jzon::stringify(chats.clone())),
("loginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)),
("eventloginbonus", format!(r#"{{"last_rewarded": 0, "bonus_list": [], "start_time": {}}}"#, now)),
("sifcards", String::from("[]")),
("friends", String::from(r#"{"friend_user_id_list":[],"request_user_id_list":[],"pending_user_id_list":[]}"#)),
("event", String::from("{}")),
("exchange", String::from("[]")),
("server_data", format!(r#"{{"server_time_set":{},"server_time":1709272800}}"#, now))
]
}
// The rows delete_account (mod.rs:791) removes that write_starter_rows does not
// write back: everything an account owns that is a credential rather than
// progress. `tokens` is not here because the starter write issues a fresh one in
// the same transaction, and `userdata` and its ten side tables are not here
// because they are rewritten. Anything added to delete_account's list belongs in
// one of those three places or the guest reset starts leaking again.
const CARRIED_CREDENTIAL_TABLES: &[&str] = &["migration", "webui"];
// Writes the whole account inside one transaction: the eleven data rows back to
// the starter state, every carried-over credential gone, and a login token
// nobody has seen before. Upserts rather than inserts, so the same code both
// creates an account and rewrites an existing one - the reset can never
// half-apply, and it cannot miss a table that creation seeds because there is
// only one list. Returns the account's new login token.
fn write_starter_rows(uid: i64, name: &str) -> Result<String, rusqlite::Error> {
let (user, home, missions, chats) = tutorial_complete(uid, name);
let rows = side_rows(&chats, &home, &missions);
let friend_request_disabled = user["user"]["friend_request_disabled"].as_i32().unwrap_or(1);
let protocol_version = if card::account_has_custom_cards(&user) { card::PROTOCOL_VERSION } else { 0 };
let userdata = jzon::stringify(user);
let token = global::create_token();
DATABASE.lock_and_transact(|conn| {
conn.execute(
"INSERT INTO userdata (user_id, userdata, friend_request_disabled, protocol_version) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(user_id) DO UPDATE SET userdata=?2, friend_request_disabled=?3, protocol_version=?4",
params!(uid, &userdata, friend_request_disabled, protocol_version)
)?;
for (table, value) in &rows {
conn.execute(
&format!(
"INSERT INTO {0} (user_id, {0}) VALUES (?1, ?2) ON CONFLICT(user_id) DO UPDATE SET {0}=?2",
table
),
params!(uid, value)
)?;
}
for table in CARRIED_CREDENTIAL_TABLES {
conn.execute(&format!("DELETE FROM {} WHERE user_id=?1", table), params!(uid))?;
}
// The account's one login token, re-drawn. The DELETE by token is
// create_acc's own collision guard (mod.rs:238); the DELETE by user id is
// what makes the INSERT a rotation rather than a primary-key conflict.
conn.execute("DELETE FROM tokens WHERE token=?1", params!(&token))?;
conn.execute("DELETE FROM tokens WHERE user_id=?1", params!(uid))?;
conn.execute("INSERT INTO tokens (user_id, token) VALUES (?1, ?2)", params!(uid, &token))?;
Ok(token)
})
}
// A brand new account, already through the tutorial. Returns its user id and its
// login token - the uuid the client stores and authenticates with from then on.
pub fn create(name: &str) -> Option<(i64, String)> {
let uid = generate_uid();
match write_starter_rows(uid, name) {
Ok(token) => Some((uid, token)),
Err(err) => {
println!("arcade: could not create account {}: {}", uid, err);
None
}
}
}
// Rewrites an existing account back to the starter state, keeping its user id:
// the cabinet's guest, at the start of every credit. Returns the account's new
// login token, which /api/arcade/session answers with - the previous player's
// copy of the old one stops working the moment their credit ends.
pub fn reset(uid: i64, name: &str) -> Option<String> {
if !acc_exists(uid) {
return None;
}
let token = match write_starter_rows(uid, name) {
Ok(token) => token,
Err(err) => {
println!("arcade: could not reset account {}: {}", uid, err);
return None;
}
};
// The gree device certificate is the one account-owned credential that lives
// in another database, so it cannot ride the transaction above - but it is on
// delete_account's list for the same reason the two tables are, and a stale
// one would still name this account (database/gree.rs:98).
crate::database::gree::delete_uuid(uid);
Some(token)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::router::userdata;
fn stored(uid: i64) -> JsonValue {
jzon::parse(&DATABASE.lock_and_select("SELECT userdata FROM userdata WHERE user_id=?1", params!(uid)).unwrap()).unwrap()
}
fn row(uid: i64, table: &str) -> String {
DATABASE.lock_and_select(&format!("SELECT {0} FROM {0} WHERE user_id=?1", table), params!(uid)).unwrap()
}
// A created account is already past the tutorial: named, nine cards, a full
// deck with the favourite in the centre, gems, title, bond and full stamina
#[test]
fn a_created_account_is_tutorial_complete() {
let _lock = crate::runtime::lock_test_data_path();
let (uid, token) = create("Cabinet 1").unwrap();
assert_eq!(userdata::uid_from_login_token(&token), uid);
let user = stored(uid);
assert_eq!(user["user"]["id"].as_i64(), Some(uid));
assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1"));
// 130 is what every gate downstream tests for (live.rs:92, :385, :435)
assert_eq!(user["tutorial_step"].as_i64(), Some(130));
assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100));
assert_eq!(user["gem"]["free"].as_i64(), Some(3000));
assert_eq!(user["gem"]["total"].as_i64(), Some(3000));
assert_eq!(user["user"]["master_title_ids"][0].as_i64(), Some(3000001));
assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(STARTER_UR));
assert_eq!(user["character_list"][0]["master_character_id"].as_i64(), Some(STARTER_CHARACTER_ID));
assert_eq!(user["card_list"].len(), STARTER_CARDS.len());
for id in STARTER_CARDS {
assert!(user["card_list"].members().any(|c| c["master_card_id"] == *id), "missing {}", id);
}
// The deck is whole: nine distinct cards, the favourite in the centre
let deck = &user["deck_list"][0]["main_card_ids"];
assert_eq!(deck.len(), 9);
assert_eq!(deck[4].as_i64(), Some(STARTER_UR));
let mut seen = Vec::new();
for slot in deck.members() {
let id = slot.as_i64().unwrap();
assert!(id != 0, "empty deck slot");
assert!(!seen.contains(&id), "duplicate card {} in the deck", id);
seen.push(id);
}
// The home row carries the favourite too, and every side row exists
assert_eq!(jzon::parse(&row(uid, "userhome")).unwrap()["home"]["preset_setting"][0]["illust_master_card_id"].as_i64(), Some(STARTER_UR));
assert_eq!(row(uid, "sifcards"), "[]");
assert_eq!(row(uid, "exchange"), "[]");
assert_eq!(row(uid, "event"), "{}");
assert!(!jzon::parse(&row(uid, "missions")).unwrap().is_empty());
assert!(!jzon::parse(&row(uid, "chats")).unwrap().is_empty());
// The account is NOT one of the dead ones the purge sweeper collects
// (mod.rs:792-801): it has cards, a real name and step 130
assert!(!user["card_list"].is_empty());
assert_ne!(user["user"]["name"].as_str(), Some("Tutorial in progress"));
}
// The guest reset keeps the identity and the token and throws away
// everything the last player did with it
#[test]
fn a_reset_keeps_the_identity_and_wipes_the_progress() {
let _lock = crate::runtime::lock_test_data_path();
let (uid, token) = create("GUEST").unwrap();
// A credit's worth of play, on every row a reset has to reach
let mut user = stored(uid);
user["user"]["name"] = "Somebody".into();
user["user"]["exp"] = (12345).into();
user["stamina"]["stamina"] = (3).into();
user["gem"]["free"] = (99999).into();
user["live_list"].push(object!{ master_live_id: 1100101, level: 4, clear_count: 7, high_score: 654321, max_combo: 300 }).unwrap();
user["live_mission_list"].push(object!{ master_live_id: 1100101, clear_master_live_mission_ids: [1, 2] }).unwrap();
user["item_list"].push(object!{ id: 17001001, master_item_id: 17001001, amount: 50 }).unwrap();
user["card_list"].push(object!{ id: 10010013, master_card_id: 10010013, exp: 0, skill_exp: 0, evolve: [], created_date_time: 0 }).unwrap();
userdata::save_acc(&token, user);
userdata::save_acc_friends(&token, object!{
friend_user_id_list: [1],
request_user_id_list: [],
pending_user_id_list: []
});
userdata::save_server_data(&token, object!{ last_live_started: [object!{ master_live_id: 1100101 }] });
userdata::save_acc_exchange(&token, array![1, 2, 3]);
let new_token = reset(uid, "Cabinet 1").expect("the reset was refused");
// Same account, a new login token - the cabinet is handed the new one in
// the /api/arcade/session answer, and the previous player's copy of the
// old one now names nothing
assert_ne!(new_token, token, "the guest's login token was reused");
assert_eq!(userdata::uid_from_login_token(&new_token), uid);
assert_eq!(userdata::uid_from_login_token(&token), 0, "the previous credit's token still logs in");
let user = stored(uid);
assert_eq!(user["user"]["id"].as_i64(), Some(uid));
assert_eq!(user["user"]["name"].as_str(), Some("Cabinet 1"));
assert_eq!(user["user"]["exp"].as_i64(), Some(0));
assert_eq!(user["stamina"]["stamina"].as_i64(), Some(100));
assert_eq!(user["gem"]["free"].as_i64(), Some(3000));
assert_eq!(user["live_list"].len(), 0);
assert_eq!(user["live_mission_list"].len(), 0);
assert_eq!(user["item_list"].len(), 0);
assert_eq!(user["card_list"].len(), STARTER_CARDS.len());
assert_eq!(user["deck_list"][0]["main_card_ids"][4].as_i64(), Some(STARTER_UR));
// Every side row went back too
assert_eq!(jzon::parse(&row(uid, "friends")).unwrap()["friend_user_id_list"].len(), 0);
assert!(jzon::parse(&row(uid, "server_data")).unwrap()["last_live_started"].is_null());
assert_eq!(row(uid, "exchange"), "[]");
}
// Resetting something that is not an account is refused rather than
// conjuring one out of nothing
#[test]
fn resetting_an_unknown_account_is_refused() {
let _lock = crate::runtime::lock_test_data_path();
assert!(reset(123, "Cabinet 1").is_none());
}
// A guest is handed to a stranger every credit, so a reset has to take every
// credential the last player could have left on it - not just the progress.
// The list is delete_account's (mod.rs:791) minus the rows the starter write
// rewrites and the token it re-draws.
#[test]
fn a_reset_takes_every_credential_the_last_player_left() {
let _lock = crate::runtime::lock_test_data_path();
let (uid, token) = create("GUEST").unwrap();
// The previous player, on the cabinet during their credit: a transfer
// code and password (/api/user/registerpassword), a webui session logged
// in with them, and a gree device certificate.
let code = userdata::user::migration::save_acc_transfer(uid, "hunter2");
assert!(userdata::has_transfer_password(uid));
let webui_token = userdata::webui_login(uid, "hunter2").expect("the webui login was refused");
assert_eq!(userdata::webui_login_token(&webui_token).as_deref(), Some(token.as_str()));
crate::database::gree::update_cert(uid, "a device certificate");
assert!(crate::database::gree::get_user_cert(&token).is_some());
let new_token = reset(uid, "Cabinet 1").expect("the reset was refused");
// The transfer code and password are gone: neither the game's own
// takeover check nor the webui login answers to them any more
assert!(!userdata::has_transfer_password(uid), "the transfer password survived the reset");
assert!(!userdata::user::migration::get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap_or(false),
"the previous player's transfer code still takes the account over");
assert!(userdata::webui_login(uid, "hunter2").is_err(), "the previous player can still log the webui in");
// The webui session they left open is gone too
assert!(userdata::webui_login_token(&webui_token).is_none(), "the previous player's webui session survived the reset");
assert!(userdata::webui_get_user(&webui_token).is_none());
// So is the device certificate, which named the account from a phone
assert!(crate::database::gree::get_user_cert(&token).is_none(), "the gree certificate survived the reset");
assert!(crate::database::gree::get_user_cert(&new_token).is_none());
// And the account is still perfectly usable under its new token
assert_eq!(userdata::uid_from_login_token(&new_token), uid);
assert_eq!(stored(uid)["user"]["name"].as_str(), Some("Cabinet 1"));
}
// Every table delete_account clears is either rewritten by the starter write,
// re-drawn (the token) or deleted by it. A row added to delete_account without
// a decision here is a leak across credits, so the lists are compared rather
// than trusted to have been kept in step.
#[test]
fn the_reset_accounts_for_every_table_a_deletion_clears() {
let rewritten = ["userdata", "userhome", "missions", "chats", "loginbonus",
"eventloginbonus", "sifcards", "friends", "event", "exchange", "server_data"];
for table in userdata::ACCOUNT_TABLES {
assert!(
rewritten.contains(table) || CARRIED_CREDENTIAL_TABLES.contains(table) || *table == "tokens",
"delete_account clears {} and the guest reset does nothing about it",
table
);
}
// And the side-row list really is what the reset rewrites
let rows = side_rows(&array![], &object!{}, &object!{});
for (table, _) in &rows {
assert!(rewritten.contains(table), "{} is written by the reset but not listed above", table);
}
assert_eq!(rows.len() + 1, rewritten.len());
}
// Cabinet names arrive from an operator typing into a machine
#[test]
fn names_are_clamped_not_trusted() {
assert_eq!(clean_name(" Cabinet 1 ", "ARCADE"), "Cabinet 1");
assert_eq!(clean_name("", "ARCADE"), "ARCADE");
assert_eq!(clean_name(" ", "ARCADE"), "ARCADE");
assert_eq!(clean_name("a\nb\tc", "ARCADE"), "abc");
assert_eq!(clean_name(&"x".repeat(200), "ARCADE").len(), MAX_NAME_LEN);
}
}
+208 -17
View File
@@ -12,30 +12,116 @@ use base64::{Engine as _, engine::general_purpose};
fn generate_token() -> String {
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let mut rng = rand::rng();
let random_string: String = (0..16)
.map(|_| {
let idx = rng.random_range(0..charset.len());
charset.chars().nth(idx).unwrap()
})
.collect();
random_string
loop {
let random_string: String = (0..16)
.map(|_| {
let idx = rng.random_range(0..charset.len());
charset.chars().nth(idx).unwrap()
})
.collect();
if !random_string.chars().all(|c| c.is_ascii_digit()) {
return random_string;
}
}
}
// A linked NESiCA card id works as the transfer code; the password is still required
pub fn get_acc_transfer(token: &str, password: &str) -> JsonValue {
let database = userdata::get_userdata_database();
let data = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token));
if data.is_err() {
return object!{success: false};
}
if verify_password(password, &data.unwrap()) {
let uid: i64 = database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap();
let login_token = userdata::get_login_token(uid);
if login_token == String::new() {
let uid: i64 = if let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE token=?1", params!(token)) {
if !verify_password(password, &hash) {
return object!{success: false};
}
return object!{success: true, login_token: login_token, user_id: uid};
database.lock_and_select_type("SELECT user_id FROM migration WHERE token=?1", params!(token)).unwrap()
} else {
let Some(uid) = valid_card_id(token).and_then(|card| card_user(&card)) else {
return object!{success: false};
};
let Ok(hash) = database.lock_and_select("SELECT password FROM migration WHERE user_id=?1", params!(uid)) else {
return object!{success: false};
};
if !verify_password(password, &hash) {
return object!{success: false};
}
uid
};
let login_token = userdata::get_login_token(uid);
if login_token == String::new() {
return object!{success: false};
}
object!{success: false}
object!{success: true, login_token: login_token, user_id: uid}
}
// 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()
|| valid_card_id(token).and_then(|card| card_user(&card)).is_some()
}
pub fn valid_card_id(card_id: &str) -> Option<String> {
let card_id = card_id.trim().to_string();
if card_id.is_empty() || card_id.len() > 32 || !card_id.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
Some(card_id)
}
pub fn card_user(card_id: &str) -> Option<i64> {
userdata::get_userdata_database().lock_and_select_type("SELECT user_id FROM cards WHERE card_id=?1", params!(card_id)).ok()
}
pub fn cards_of_account(user_id: i64) -> Vec<String> {
let Ok(conn) = rusqlite::Connection::open(userdata::get_userdata_database().get_path()) else { return Vec::new(); };
let Ok(mut stmt) = conn.prepare("SELECT card_id FROM cards WHERE user_id=?1 ORDER BY created ASC") else { return Vec::new(); };
let Ok(rows) = stmt.query_map(params!(user_id), |row| row.get::<usize, String>(0)) else { return Vec::new(); };
rows.flatten().collect()
}
pub fn account_has_card(user_id: i64) -> bool {
userdata::get_userdata_database().lock_and_select("SELECT card_id FROM cards WHERE user_id=?1", params!(user_id)).is_ok()
}
pub fn set_card(card_id: &str, user_id: i64) {
userdata::get_userdata_database().lock_and_exec(
"INSERT INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3) ON CONFLICT(card_id) DO UPDATE SET user_id=?2",
params!(card_id, user_id, crate::router::global::timestamp() as i64)
);
}
pub fn import_card(card_id: &str, user_id: i64, created: i64) {
userdata::get_userdata_database().lock_and_exec(
"INSERT OR IGNORE INTO cards (card_id, user_id, created) VALUES (?1, ?2, ?3)",
params!(card_id, user_id, created)
);
}
pub fn remove_card(card_id: &str) {
userdata::get_userdata_database().lock_and_exec("DELETE FROM cards WHERE card_id=?1", params!(card_id));
}
pub fn remove_card_of(card_id: &str, user_id: i64) -> bool {
match card_user(card_id) {
Some(owner) if owner == user_id => {
remove_card(card_id);
true
}
_ => false
}
}
// The one rule for linking a card, whoever proved the account (game session, webui session, cabinet transfer pair)
pub fn link_card(card_id: &str, user_id: i64) -> Result<(String, bool), String> {
let Some(card) = valid_card_id(card_id) else {
return Err(String::from("That is not a usable card id"));
};
if crate::router::arcade::is_cabinet_account(user_id) {
return Err(String::from("That account belongs to an arcade cabinet"));
}
let previous = card_user(&card);
set_card(&card, user_id);
crate::router::arcade::card_relinked(&card);
Ok((card, previous.is_some_and(|p| p != user_id)))
}
pub fn save_acc_transfer(uid: i64, password: &str) -> String {
@@ -91,6 +177,13 @@ pub fn setup_sql(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
PRIMARY KEY (user_id, token)
);
", [])?;
conn.execute("
CREATE TABLE IF NOT EXISTS cards (
card_id TEXT NOT NULL PRIMARY KEY,
user_id BIGINT NOT NULL,
created BIGINT NOT NULL
);
", [])?;
let is_updated = conn.prepare("SELECT user_id FROM migration LIMIT 1;").is_ok();
if is_updated { return Ok(()); }
println!("Upgrading migration table");
@@ -155,3 +248,101 @@ fn legacy_verify_password(password: &str, salted_hash: &str) -> bool {
let input_hash = hasher.finalize();
input_hash.as_slice() == hashed_password
}
// whenever an ai touches this codebase it decides to write 200000 lines of tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_account_lists_and_unlinks_only_its_own_cards() {
let _lock = crate::runtime::lock_test_data_path();
let (mine, _) = userdata::starter::create("Mine").unwrap();
let (theirs, _) = userdata::starter::create("Theirs").unwrap();
assert!(cards_of_account(mine).is_empty());
set_card("7020392000000011", mine);
set_card("7020392000000012", mine);
set_card("7020392000000013", theirs);
assert_eq!(cards_of_account(mine), vec!["7020392000000011".to_string(), "7020392000000012".to_string()]);
assert_eq!(cards_of_account(theirs), vec!["7020392000000013".to_string()]);
assert!(account_has_card(mine));
assert!(!remove_card_of("7020392000000013", mine));
assert_eq!(card_user("7020392000000013"), Some(theirs));
assert!(!remove_card_of("7020392000000014", mine));
assert!(remove_card_of("7020392000000011", mine));
assert!(card_user("7020392000000011").is_none());
assert_eq!(cards_of_account(mine), vec!["7020392000000012".to_string()]);
remove_card("7020392000000012");
remove_card("7020392000000013");
assert!(!account_has_card(mine));
userdata::delete_account(mine);
userdata::delete_account(theirs);
}
#[test]
fn card_ids_are_validated_not_sanitised() {
assert_eq!(valid_card_id("0123456789012345").as_deref(), Some("0123456789012345"));
assert_eq!(valid_card_id(" 0123456789012345 ").as_deref(), Some("0123456789012345"));
assert!(valid_card_id("").is_none());
assert!(valid_card_id("0123-4567").is_none());
assert!(valid_card_id("'; DROP TABLE cards--").is_none());
assert!(valid_card_id(&"x".repeat(33)).is_none());
}
#[test]
fn link_card_repoints_a_linked_card_and_says_so() {
let _lock = crate::runtime::lock_test_data_path();
let (first, _) = userdata::starter::create("First").unwrap();
let (second, _) = userdata::starter::create("Second").unwrap();
let card = "7020392000000031";
assert!(link_card("7020-3920", first).is_err());
assert_eq!(link_card(card, first), Ok((card.to_string(), false)));
assert_eq!(link_card(card, first), Ok((card.to_string(), false)));
assert_eq!(link_card(card, second), Ok((card.to_string(), true)));
assert_eq!(card_user(card), Some(second));
assert!(!userdata::get_acc_from_uid(first)["error"].as_bool().unwrap_or(false));
remove_card(card);
userdata::delete_account(first);
userdata::delete_account(second);
}
#[test]
fn a_linked_card_is_a_transfer_code_with_the_password_still_required() {
let _lock = crate::runtime::lock_test_data_path();
let (player, token) = userdata::starter::create("Card Transfer").unwrap();
let card = "7020392000000021";
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
assert!(!transfer_code_exists(card));
set_card(card, player);
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
assert!(!get_acc_transfer(card, "")["success"].as_bool().unwrap());
assert!(transfer_code_exists(card));
save_acc_transfer(player, "hunter2");
let by_card = get_acc_transfer(card, "hunter2");
assert!(by_card["success"].as_bool().unwrap());
assert_eq!(by_card["user_id"].as_i64(), Some(player));
assert_eq!(by_card["login_token"].as_str(), Some(token.as_str()));
assert!(!get_acc_transfer(card, "wrong")["success"].as_bool().unwrap());
let code = get_acc_token(player);
assert!(get_acc_transfer(&code, "hunter2")["success"].as_bool().unwrap());
assert!(!code.chars().all(|c| c.is_ascii_digit()));
remove_card(card);
assert!(!get_acc_transfer(card, "hunter2")["success"].as_bool().unwrap());
userdata::delete_account(player);
}
}
+518 -3
View File
@@ -1,6 +1,521 @@
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() {
return Some(key);
}
let uid = global::get_uid(req.headers());
if uid == 0 {
return None;
}
let key = userdata::get_login_token(uid);
if key.is_empty() { None } else { Some(key) }
}
fn read_set(req: &HttpRequest) -> HashSet<i64> {
match player_key(req) {
Some(key) => userdata::get_acc_home(&key)["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect(),
None => HashSet::new()
}
}
fn mark_read(key: &str, ids: &[i64]) {
let mut user = userdata::get_acc_home(key);
let mut set: HashSet<i64> = user["home"]["read_announcement_ids"].members().filter_map(|v| v.as_i64()).collect();
for id in ids {
set.insert(*id);
}
let mut sorted: Vec<i64> = set.into_iter().collect();
sorted.sort();
let mut arr = array![];
for id in sorted {
arr.push(id).unwrap();
}
user["home"]["read_announcement_ids"] = arr;
userdata::save_acc_home(key, user);
}
fn display_date(published_at: i64) -> String {
if published_at <= 0 {
return String::new();
}
let s = global::format_datetime(published_at as u64);
format!("{}/{}/{} {}", &s[0..4], &s[5..7], &s[8..10], &s[11..16])
}
fn page_head() -> String {
String::from(r#"<!DOCTYPE html>n<html lang="ja-JP"><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=320, initial-scale=1.0, user-scalable=no">
<meta name="format-detection" content="telephone=no">
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/web/announcement/assets/sanitize.css" type="text/css">
<link rel="stylesheet" href="/web/announcement/assets/news_common.css" type="text/css">
<style>#tab div.on{background-image:none;background-color:#f93981;border-radius:0.6vw;}#news_list hr,#detail hr{background-image:none;background-color:#dfe6e6;}</style>
<script src="/web/announcement/assets/jquery-3.6.0.min.js"></script>
<script>var clicked=false;$(function(){$("a.once").on('click',function(){if(clicked){return false;}clicked=true;return true;});});window.addEventListener('pageshow',function(e){if(e.persisted){clicked=false;}});</script>
</head><body>"#)
}
fn page_foot() -> String {
String::from("</body></html>")
}
fn tab_bar(active: i64, read: &HashSet<i64>, bulk: bool) -> String {
let mut tabs = String::new();
for (cat, class, label) in CATEGORY_LABELS {
let unread = !bulk && announcements::visible_ids(Some(*cat)).iter().any(|id| !read.contains(id));
let badge = if unread {
String::from("<span class=\"new\"><img class=\"bg_badge_eff\" src=\"/web/announcement/assets/news_bg_badge_eff.png\"><img class=\"bg_badge\" src=\"/web/announcement/assets/news_bg_badge.png\"></span>")
} else {
String::new()
};
let inner = if *cat == active {
format!("<div class=\"on\">{label}</div>")
} else {
format!("<a class=\"once\" href=\"/web/announcement?category={cat}\">{label}</a>")
};
tabs.push_str(&format!("<div class=\"{class}\">{badge}{inner}</div>"));
}
let read_btn = if bulk {
String::from("<div class=\"read_btn\"><img class=\"off\" src=\"/web/announcement/assets/news_btn_bulk.png\"></div>")
} else {
format!("<div class=\"read_btn\"><a class=\"once\" href=\"/web/announcement/bulkRead?category={active}&amp;page=1\"><img src=\"/web/announcement/assets/news_btn_bulk.png\"></a></div>")
};
format!("<div id=\"header\"><div id=\"tab\">{tabs}{read_btn}</div></div><div id=\"tab_bottom\"></div>")
}
fn render_list(category: i64, read: &HashSet<i64>, bulk: bool) -> String {
let mut list = String::new();
let items = announcements::list_category(category);
if items.is_empty() {
list.push_str(&format!(r#"
<div class="info_title"><span class="title_text">No announcements right now</span></div>
"#));
}
for item in items.members() {
let id = item["id"].as_i64().unwrap_or(0);
let banner_url = if item["has_banner"].as_bool().unwrap_or(false) {
format!("/web/announcement/banner/{id}.png")
} else {
String::from("/web/announcement/assets/news_banner_generic_news.png")
};
let kind = item["type"].as_str().unwrap_or("news");
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
let update_text = if item["updated"].as_bool().unwrap_or(false) { "<span class=\"update_text\">- update</span>" } else { "" };
let new_badge = if !bulk && !read.contains(&id) {
String::from("<div class=\"info_new_image\"><img class=\"new\" src=\"/web/announcement/assets/news_icon_new.png\"></div>")
} else {
String::new()
};
let title = item["title"].as_str().unwrap_or("");
list.push_str(&format!(r#"
<li class="list_area"><div class="information_area"><div id="{id}" class="anchor"></div>
<a href="/web/announcement/detail?announcement_id={id}&amp;page=1" class="news">
<div class="main_image"><span class="banner"><img src="{banner_url}"></span></div>
<div class="information">
<div class="info_type_image"><img class="tag" src="/web/announcement/assets/news_icon_{kind}.png"></div>
<div class="info_date"><span class="date_text">{date}</span>{update_text}</div>
{new_badge}
<div class="clear"></div>
<div class="info_title"><span class="title_text">{title}</span></div>
</div>
<div class="arrow_image"><img class="arrow" src="/web/announcement/assets/news_img_arrow.png"></div>
</a></div><div class="clear"></div><hr></li>
"#));
}
format!("{}{}<div id=\"news_list\"><ul>{}</ul><div id=\"page_area\"><div id=\"paging\"><span class=\"page off\">1</span></div></div></div>{}",
page_head(), tab_bar(category, read, bulk), list, page_foot())
}
fn render_detail(item: &JsonValue, read: &HashSet<i64>) -> String {
let category = item["category"].as_i64().unwrap_or(1);
let title = item["title"].as_str().unwrap_or("");
let date = display_date(item["published_at"].as_i64().unwrap_or(0));
let body = item["body"].as_str().unwrap_or("");
let banner = if item["has_banner"].as_bool().unwrap_or(false) {
let id = item["id"].as_i64().unwrap_or(0);
format!("<div class=\"detail_image\" style=\"display:block\"><img src=\"/web/announcement/banner/{id}.png\"></div>")
} else {
String::new()
};
format!("{}{}<div id=\"detail\"><div class=\"detail_text\"><div class=\"title\">{title}</div><div class=\"date\">{date}</div>{banner}<hr class=\"hr_detail\">{body}</div></div>{}",
page_head(), tab_bar(category, read, false), page_foot())
}
fn html(body: String) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType::html())
.body(body)
}
fn redirect_list(category: i64) -> HttpResponse {
HttpResponse::Found()
.insert_header(("Location", format!("/web/announcement?category={category}")))
.body("")
}
async fn list(req: HttpRequest) -> HttpResponse {
let category = query_i64(&req, "category", 1);
let category = if announcements::is_valid_category(category) { category } else { 1 };
html(render_list(category, &read_set(&req), false))
}
async fn detail(req: HttpRequest) -> HttpResponse {
let id = query_i64(&req, "announcement_id", 0);
let Some(item) = announcements::get(id) else {
return redirect_list(1);
};
if !item["visible"].as_bool().unwrap_or(false) {
return redirect_list(item["category"].as_i64().unwrap_or(1));
}
if let Some(key) = player_key(&req) {
mark_read(&key, &[id]);
}
html(render_detail(&item, &read_set(&req)))
}
async fn bulk_read(req: HttpRequest) -> HttpResponse {
let category = query_i64(&req, "category", 1);
let category = if announcements::is_valid_category(category) { category } else { 1 };
if let Some(key) = player_key(&req) {
mark_read(&key, &announcements::visible_ids(Some(category)));
}
html(render_list(category, &read_set(&req), true))
}
async fn asset(req: HttpRequest) -> HttpResponse {
let file = req.match_info().get("file").unwrap_or("");
let Some(file) = ASSETS.get_file(file) else {
return HttpResponse::NotFound().finish();
};
let body = file.contents();
let mime = mime_guess::from_path(file.path()).first_or_octet_stream();
HttpResponse::Ok()
.insert_header(ContentType(mime))
.insert_header(("content-length", body.len()))
.body(body)
}
fn png_response(bytes: Option<Vec<u8>>) -> HttpResponse {
match bytes {
Some(bytes) => HttpResponse::Ok()
.insert_header(ContentType::png())
.insert_header(("content-length", bytes.len()))
.body(bytes),
None => HttpResponse::NotFound().finish()
}
}
fn banner_id(req: &HttpRequest) -> i64 {
req.match_info().get("id").unwrap_or("").trim_end_matches(".png").parse::<i64>().unwrap_or(0)
}
async fn banner_image(req: HttpRequest) -> HttpResponse {
png_response(announcements::get_public_banner(banner_id(&req)))
}
async fn admin_banner_image(req: HttpRequest) -> HttpResponse {
if disabled() {
return HttpResponse::NotFound().finish();
}
if manager_uid(&req).filter(|uid| permissions::has(*uid, permissions::ANNOUNCEMENT_MANAGE)).is_none() {
return HttpResponse::NotFound().finish();
}
png_response(announcements::get_banner(banner_id(&req)))
}
type Fields = HashMap<String, Vec<u8>>;
async fn read_multipart(mut payload: Multipart) -> Result<Fields, String> {
let mut fields = Fields::new();
let mut total = 0usize;
while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? {
let name = field.name().unwrap_or("").to_string();
let mut data = Vec::new();
while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? {
total += chunk.len();
if total > MAX_BANNER_BYTES {
return Err(format!("Upload exceeds the {} MB limit", MAX_BANNER_BYTES / (1024 * 1024)));
}
data.extend_from_slice(&chunk);
}
fields.insert(name, data);
}
Ok(fields)
}
fn field_str(fields: &Fields, key: &str) -> String {
String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string()
}
fn field_flag(fields: &Fields, key: &str) -> bool {
matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on")
}
fn file_of<'a>(fields: &'a Fields, key: &str) -> Option<&'a Vec<u8>> {
fields.get(key).filter(|v| !v.is_empty())
}
fn process_banner(bytes: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(bytes).map_err(|_| String::from("The banner is not a decodable image (png, jpg and webp work)"))?;
if img.width() > MAX_BANNER_DIM || img.height() > MAX_BANNER_DIM {
return Err(format!("The banner is {}x{} - neither side may exceed {}px", img.width(), img.height(), MAX_BANNER_DIM));
}
let img = img.to_rgba8();
let scale = f64::max(BANNER_W as f64 / img.width() as f64, BANNER_H as f64 / img.height() as f64);
let scaled_w = ((img.width() as f64 * scale).round() as u32).max(1);
let scaled_h = ((img.height() as f64 * scale).round() as u32).max(1);
if (scaled_w as u64) * (scaled_h as u64) > MAX_SCALED_PIXELS {
return Err(format!("The banner is too far from the {}x{} banner shape to be cropped", BANNER_W, BANNER_H));
}
let scaled = image::imageops::resize(&img, scaled_w, scaled_h, image::imageops::FilterType::Lanczos3);
let x = (scaled.width() - BANNER_W.min(scaled.width())) / 2;
let y = (scaled.height() - BANNER_H.min(scaled.height())) / 2;
let cropped = image::imageops::crop_imm(&scaled, x, y, BANNER_W, BANNER_H).to_image();
let mut rv = Vec::new();
image::DynamicImage::ImageRgba8(cropped).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).map_err(|e| e.to_string())?;
Ok(rv)
}
fn send_json(resp: JsonValue) -> HttpResponse {
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
fn manager_uid(req: &HttpRequest) -> Option<i64> {
let token = webui::get_login_token(req)?;
let login_token = userdata::webui_login_token(&token)?;
userdata::get_acc(&login_token)["user"]["id"].as_i64()
}
fn require_manager(req: &HttpRequest) -> Result<i64, HttpResponse> {
if disabled() {
return Err(HttpResponse::NotFound().finish());
}
let Some(uid) = manager_uid(req) else {
return Err(webui::error("Not logged in"));
};
if !permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE) {
return Err(webui::error("You do not have permission to manage announcements"));
}
Ok(uid)
}
async fn admin_list(req: HttpRequest) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let mut categories = array![];
for (id, key, label) in CATEGORY_LABELS {
categories.push(object!{ id: *id, key: *key, label: *label }).unwrap();
}
let mut types = array![];
for kind in announcements::TYPES {
types.push(*kind).unwrap();
}
send_json(object!{
result: "OK",
data: {
announcements: announcements::get_all(),
categories: categories,
types: types
}
})
}
async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse {
let uid = match require_manager(&req) {
Ok(uid) => uid,
Err(resp) => return resp
};
let fields = match read_multipart(payload).await {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match save_new(uid, &fields) {
Ok(id) => send_json(object!{ result: "OK", id: id }),
Err(e) => webui::error(&e)
}
}
fn published_at_of(raw: &str) -> i64 {
if raw.is_empty() {
global::timestamp() as i64
} else {
global::parse_datetime(raw).map(|t| t as i64).unwrap_or_else(|| global::timestamp() as i64)
}
}
fn save_new(uid: i64, fields: &Fields) -> Result<i64, String> {
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
if !announcements::is_valid_category(category) {
return Err(String::from("Invalid category"));
}
let kind = field_str(fields, "type");
if !announcements::is_valid_type(&kind) {
return Err(String::from("Invalid type"));
}
let title = field_str(fields, "title");
if title.is_empty() {
return Err(String::from("A title is required"));
}
let body = field_str(fields, "body");
let banner = match file_of(fields, "banner") {
Some(bytes) => Some(process_banner(bytes)?),
None => None
};
Ok(announcements::create(category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at_of(&field_str(fields, "published_at")), uid))
}
async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let fields = match read_multipart(payload).await {
Ok(fields) => fields,
Err(e) => return webui::error(&e)
};
match save_update(&fields) {
Ok(id) => send_json(object!{ result: "OK", id: id }),
Err(e) => webui::error(&e)
}
}
fn save_update(fields: &Fields) -> Result<i64, String> {
let id = field_str(fields, "id").parse::<i64>().unwrap_or(0);
let Some(stored) = announcements::get(id) else {
return Err(String::from("That announcement no longer exists"));
};
let category = field_str(fields, "category").parse::<i64>().unwrap_or(0);
if !announcements::is_valid_category(category) {
return Err(String::from("Invalid category"));
}
let kind = field_str(fields, "type");
if !announcements::is_valid_type(&kind) {
return Err(String::from("Invalid type"));
}
let title = field_str(fields, "title");
if title.is_empty() {
return Err(String::from("A title is required"));
}
let body = field_str(fields, "body");
let banner = if let Some(bytes) = file_of(fields, "banner") {
Banner::Set(process_banner(bytes)?)
} else if field_flag(fields, "remove_banner") {
Banner::Clear
} else {
Banner::Keep
};
let published_at = if field_str(fields, "published_at").is_empty() {
stored["published_at"].as_i64().unwrap_or_else(|| global::timestamp() as i64)
} else {
published_at_of(&field_str(fields, "published_at"))
};
announcements::update(id, category, &kind, &title, &body, banner, field_flag(fields, "updated"), !field_flag(fields, "hidden"), published_at);
Ok(id)
}
async fn delete(req: HttpRequest, body: String) -> HttpResponse {
if let Err(resp) = require_manager(&req) {
return resp;
}
let body = jzon::parse(&body).unwrap_or(object!{});
let id = body["id"].as_i64().unwrap_or(0);
if announcements::get(id).is_none() {
return webui::error("That announcement no longer exists");
}
announcements::delete(id);
send_json(object!{ result: "OK" })
}
#[cfg(test)]
mod tests {
use super::*;
fn png(w: u32, h: u32) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba([120, 90, 200, 255]));
let mut rv = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap();
rv
}
#[test]
fn banners_are_cropped_to_the_official_size() {
for (w, h) in [(420, 168), (1200, 300), (300, 1200), (64, 64)] {
let out = process_banner(&png(w, h)).unwrap();
let decoded = image::load_from_memory(&out).unwrap();
assert_eq!((decoded.width(), decoded.height()), (BANNER_W, BANNER_H), "source {}x{}", w, h);
}
assert!(process_banner(b"not an image").unwrap_err().contains("not a decodable image"));
}
#[test]
fn extreme_sources_are_refused_before_the_resize_allocates() {
let err = process_banner(&png(9000, 32)).unwrap_err();
assert!(err.contains("9000x32"), "got {}", err);
let err = process_banner(&png(24, 6000)).unwrap_err();
assert!(err.contains("banner shape"), "got {}", err);
}
}
+165 -20
View File
@@ -25,10 +25,13 @@ fn get_config() -> JsonValue {
pub fn get_login_token(req: &HttpRequest) -> Option<String> {
let blank_header = HeaderValue::from_static("");
let cookies = req.headers().get("Cookie").unwrap_or(&blank_header).to_str().unwrap_or("");
if cookies.is_empty() {
return None;
for pair in cookies.split(';') {
let Some((name, value)) = pair.split_once('=') else { continue; };
if name.trim() == "ew_token" {
return Some(value.trim().to_string());
}
}
Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::<Vec<_>>()[0].to_string())
None
}
fn session_uid(req: &HttpRequest) -> Option<i64> {
@@ -229,6 +232,8 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
account_import: get_config()["import"].as_bool().unwrap(),
custom_songs: !crate::router::custom_song::disabled(),
custom_cards: !crate::router::custom_card::disabled(),
custom_3dmv: !crate::router::custom_3dmv::disabled(),
arcade: !crate::router::arcade::disabled(),
links: {
global: args.global_android,
japan: args.japan_android,
@@ -367,9 +372,6 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
}
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 = {
let mut rv = jzon::array![];
for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
@@ -384,8 +386,6 @@ lazy_static! {
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 = {
let mut en_rows = object!{};
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
@@ -406,9 +406,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 {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
@@ -446,8 +443,6 @@ pub fn list_skill_centers(req: HttpRequest) -> HttpResponse {
.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 {
if session_uid(&req).is_none() {
return error("Not logged in");
@@ -461,8 +456,22 @@ pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp))
}
// The requesting user's own effective scopes, for webui nav gating. Any
// session may ask - it only ever reveals what the user themselves holds
pub fn custom_3dmv_limits(req: HttpRequest) -> HttpResponse {
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 {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
@@ -471,12 +480,19 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
result: "OK",
data: {
uid: uid,
scopes: permissions::scopes_for(uid),
scopes: permissions::get_user_permissions(uid),
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
can_manage_groups: permissions::has(uid, permissions::GROUP_MANAGE),
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)
|| 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()
@@ -484,8 +500,6 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
.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 {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
@@ -505,7 +519,7 @@ pub fn list_permissions(req: HttpRequest) -> HttpResponse {
uid: uid,
can_grant: can_grant,
can_revoke: can_revoke,
scopes: permissions::scopes_for(uid),
scopes: permissions::get_user_permissions(uid),
available: available,
grants: permissions::grants()
}
@@ -552,6 +566,110 @@ pub fn revoke_permission(req: HttpRequest, body: String) -> HttpResponse {
.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))
}
pub fn link_nesica_card(req: HttpRequest, body: String) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
let body = jzon::parse(&body).unwrap_or(object!{});
match crate::router::userdata::user::migration::link_card(body["card_id"].as_str().unwrap_or(""), uid) {
Ok((card_id, rebound)) => {
let resp = object!{
result: "OK",
data: {
card_id: card_id,
rebound: rebound
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
},
Err(reason) => error(&reason)
}
}
pub fn list_nesica_cards(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
let resp = object!{
result: "OK",
data: {
card_ids: crate::router::userdata::user::migration::cards_of_account(uid)
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn unlink_nesica_card(req: HttpRequest, body: String) -> HttpResponse {
let Some(uid) = session_uid(&req) else {
return error("Not logged in");
};
let body = jzon::parse(&body).unwrap_or(object!{});
let Some(card) = crate::router::userdata::user::migration::valid_card_id(body["card_id"].as_str().unwrap_or("")) else {
return error("That is not a usable card id");
};
if !crate::router::userdata::user::migration::remove_card_of(&card, uid) {
return error("That card is not linked to this account");
}
let resp = object!{
result: "OK",
data: {
card_id: card
}
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
let token = get_login_token(&req);
if token.is_none() {
@@ -592,10 +710,37 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
.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)]
mod tests {
use super::*;
// get_login_token is the ONLY authentication on every mutating custom-content
// route, so it has to read the cookie header as cookies, not as a substring:
// a name that merely ends in ew_token, or a second ew_token= appended later,
// must not win over the real session cookie
#[test]
fn the_session_cookie_is_matched_by_name() {
let token_for = |header: &str| get_login_token(
&actix_web::test::TestRequest::default().insert_header(("Cookie", header)).to_http_request()
);
assert_eq!(token_for("ew_token=real").as_deref(), Some("real"));
assert_eq!(token_for("theme=dark; ew_token=real; lang=en").as_deref(), Some("real"));
// A cookie whose NAME ends in ew_token is a different cookie
assert_eq!(token_for("not_ew_token=attacker").as_deref(), None);
assert_eq!(token_for("ew_token=real; xew_token=attacker").as_deref(), Some("real"));
// A duplicate set later in the header does not override the first
assert_eq!(token_for("ew_token=real; ew_token=attacker").as_deref(), Some("real"));
// No cookie at all is no session, not the whole header
assert_eq!(token_for("theme=dark").as_deref(), None);
assert_eq!(get_login_token(&actix_web::test::TestRequest::default().to_http_request()), None);
}
// The picker lists the card form searches by name: every baked character
// (official + SIF1 import, badged apart) and every skill_center row with
// its JP and EN display strings
+151 -2
View File
@@ -23,8 +23,31 @@ pub struct HostConfig {
pub port: u16,
pub jp_android_asset_hash: String,
pub en_android_asset_hash: String,
pub asset_version: String,
pub en_asset_version: String,
pub jp_ios_asset_hash: String,
pub en_ios_asset_hash: String,
pub windows_asset_hash: String,
pub enable_custom_songs: bool,
pub enable_custom_cards: bool,
pub enable_custom_3dmv: bool,
pub enable_arcade: bool,
pub nerf_custom_cards: bool,
pub owner: Vec<i64>,
pub hidden: bool,
pub force_updates: bool,
pub disable_imports: bool,
pub disable_exports: bool,
pub linux_asset_hash: String,
pub macos_asset_hash: String,
pub global_android: String,
pub japan_android: String,
pub global_ios: String,
pub japan_ios: String,
pub arcade_machine_ttl: u64,
pub arcade_session_ttl: u64,
pub image_asset_path: String,
pub masterdata: String,
}
// Lets an embedding app (or the tests) enable the opt-in custom songs feature
@@ -37,6 +60,24 @@ pub fn set_enable_custom_cards(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_cards = enabled;
}
pub fn set_enable_custom_3dmv(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled;
}
pub fn set_enable_arcade(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_arcade = enabled;
}
// The custom-card nerf band. Meaningful only with enable_custom_cards; kept
// separate so an embedding app can flip them independently
pub fn set_nerf_custom_cards(enabled: bool) {
HOST_CONFIG.write().unwrap().nerf_custom_cards = enabled;
}
pub fn get_nerf_custom_cards() -> bool {
HOST_CONFIG.read().unwrap().nerf_custom_cards
}
// 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
// revoked through the webui
@@ -141,6 +182,38 @@ pub fn apply_config_json(json: &str) {
cfg.port = parsed["port"].as_u64().unwrap_or(0) as u16;
cfg.jp_android_asset_hash = s("jpAndroidAssetHash");
cfg.en_android_asset_hash = s("enAndroidAssetHash");
cfg.asset_version = s("assetVersion");
cfg.en_asset_version = s("enAssetVersion");
cfg.jp_ios_asset_hash = s("jpIosAssetHash");
cfg.en_ios_asset_hash = s("enIosAssetHash");
cfg.windows_asset_hash = s("windowsAssetHash");
cfg.linux_asset_hash = s("linuxAssetHash");
cfg.macos_asset_hash = s("macosAssetHash");
cfg.global_android = s("globalAndroid");
cfg.japan_android = s("japanAndroid");
cfg.global_ios = s("globalIos");
cfg.japan_ios = s("japanIos");
cfg.arcade_machine_ttl = parsed["arcadeMachineTtl"].as_u64().unwrap_or(90);
cfg.arcade_session_ttl = parsed["arcadeSessionTtl"].as_u64().unwrap_or(30);
cfg.image_asset_path = s("imageAssetPath");
cfg.masterdata = s("masterdata");
cfg.owner = parsed["ownerUids"].members().filter_map(|v| v.as_i64()).collect();
cfg.hidden = parsed["hidden"].as_bool().unwrap_or(false);
cfg.force_updates = parsed["forceUpdates"].as_bool().unwrap_or(false);
cfg.disable_imports = parsed["disableImports"].as_bool().unwrap_or(false);
cfg.disable_exports = parsed["disableExports"].as_bool().unwrap_or(false);
cfg.enable_custom_songs = parsed["enableCustomSongs"].as_bool().unwrap_or(false);
cfg.enable_custom_cards = parsed["enableCustomCards"].as_bool().unwrap_or(false);
cfg.nerf_custom_cards = parsed["nerfCustomCards"].as_bool().unwrap_or(false);
cfg.enable_custom_3dmv = parsed["enableCustom3dmv"].as_bool().unwrap_or(false);
cfg.enable_arcade = parsed["enableArcade"].as_bool().unwrap_or(false);
let owners = cfg.owner.clone();
let masterdata = cfg.masterdata.clone();
let nerf_custom_cards = cfg.nerf_custom_cards;
drop(cfg);
update_owners(&owners);
update_masterdata_path(&masterdata);
set_nerf_custom_cards(nerf_custom_cards);
}
pub fn overlay_args(args: &mut crate::options::Args) {
@@ -162,14 +235,51 @@ pub fn overlay_args(args: &mut crate::options::Args) {
}
overlay_str!(jp_android_asset_hash);
overlay_str!(en_android_asset_hash);
// Overlay only ever enables the features; a command-line --enable-custom-songs
// / --enable-custom-cards is never overridden back to off
overlay_str!(asset_version);
overlay_str!(en_asset_version);
overlay_str!(jp_ios_asset_hash);
overlay_str!(en_ios_asset_hash);
overlay_str!(windows_asset_hash);
overlay_str!(linux_asset_hash);
overlay_str!(macos_asset_hash);
overlay_str!(global_android);
overlay_str!(japan_android);
overlay_str!(global_ios);
overlay_str!(japan_ios);
overlay_str!(image_asset_path);
overlay_str!(masterdata);
if cfg.arcade_machine_ttl != 0 {
args.arcade_machine_ttl = cfg.arcade_machine_ttl;
}
if cfg.arcade_session_ttl != 0 {
args.arcade_session_ttl = cfg.arcade_session_ttl;
}
// An embedding app may supply these options, but its default values must
// not erase flags passed by the standalone server (including Docker).
if !cfg.owner.is_empty() {
args.owner = cfg.owner.clone();
}
args.hidden |= cfg.hidden;
args.force_updates |= cfg.force_updates;
args.disable_imports |= cfg.disable_imports;
args.disable_exports |= cfg.disable_exports;
// Overlay only ever enables the features; a command-line --enable-custom-*
// flag is never overridden back to off
if cfg.enable_custom_songs {
args.enable_custom_songs = true;
}
if cfg.enable_custom_cards {
args.enable_custom_cards = true;
}
if cfg.enable_custom_3dmv {
args.enable_custom_3dmv = true;
}
if cfg.enable_arcade {
args.enable_arcade = true;
}
if cfg.nerf_custom_cards {
args.nerf_custom_cards = true;
}
}
// idk why an ai put tests here but they are here now. Yay tests????
@@ -192,5 +302,44 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
// while holding the lock
set_enable_custom_songs(true);
set_enable_custom_cards(true);
set_enable_custom_3dmv(true);
set_enable_arcade(true);
guard
}
#[cfg(test)]
#[test]
fn android_host_config_updates_owner_and_feature_options() {
use clap::Parser;
let _guard = lock_test_data_path();
apply_config_json(r#"{"ownerUids":[42,84],"hidden":true,"forceUpdates":true,"disableImports":true,"disableExports":true,"enableCustomSongs":true,"enableCustomCards":true,"nerfCustomCards":true,"enableCustom3dmv":true,"enableArcade":true,"linuxAssetHash":"linux-hash","macosAssetHash":"mac-hash","globalAndroid":"https://example.com/gl.apk","arcadeMachineTtl":45,"arcadeSessionTtl":15}"#);
let mut args = crate::options::Args::parse_from(["ew"]);
overlay_args(&mut args);
assert_eq!(get_owners(), vec![42, 84]);
assert_eq!(args.owner, vec![42, 84]);
assert!(args.hidden && args.force_updates && args.disable_imports && args.disable_exports);
assert!(args.enable_custom_songs && args.enable_custom_cards && args.nerf_custom_cards);
assert!(get_nerf_custom_cards());
assert!(args.enable_custom_3dmv && args.enable_arcade);
assert_eq!(args.linux_asset_hash, "linux-hash");
assert_eq!(args.macos_asset_hash, "mac-hash");
assert_eq!(args.global_android, "https://example.com/gl.apk");
assert_eq!(args.arcade_machine_ttl, 45);
assert_eq!(args.arcade_session_ttl, 15);
apply_config_json(r#"{"ownerUids":[],"enableCustomSongs":false}"#);
let mut reset = crate::options::Args::parse_from(["ew"]);
overlay_args(&mut reset);
assert!(get_owners().is_empty());
assert!(!reset.enable_custom_songs);
assert!(!reset.hidden);
assert!(!get_nerf_custom_cards());
let mut cli = crate::options::Args::parse_from([
"ew", "--force-updates", "--hidden", "--disable-imports",
"--disable-exports", "--owner", "123",
]);
overlay_args(&mut cli);
assert!(cli.force_updates && cli.hidden && cli.disable_imports && cli.disable_exports);
assert_eq!(cli.owner, vec![123]);
}
+13 -20
View File
@@ -4,14 +4,20 @@ use jzon::{JsonValue, array};
pub struct SQLite {
path: String
}
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn open(path: &str) -> Result<Connection, rusqlite::Error> {
let conn = Connection::open(path)?;
conn.busy_timeout(BUSY_TIMEOUT)?;
Ok(conn)
}
impl SQLite {
pub fn new(path: &str, setup: fn(&Connection)) -> SQLite {
let instance = SQLite {
path: crate::get_data_path(path)
};
let conn = Connection::open(&instance.path).unwrap();
conn.busy_timeout(std::time::Duration::from_secs(10)).unwrap();
let conn = open(&instance.path).unwrap();
conn.execute("PRAGMA foreign_keys = ON;", ()).unwrap();
setup(&conn);
instance
@@ -20,11 +26,11 @@ impl SQLite {
&self.path
}
pub fn lock_and_exec(&self, command: &str, args: &[&dyn ToSql]) {
let conn = Connection::open(&self.path).unwrap();
let conn = open(&self.path).unwrap();
conn.execute(command, args).unwrap();
}
pub fn lock_and_select(&self, command: &str, args: &[&dyn ToSql]) -> Result<String, rusqlite::Error> {
let conn = Connection::open(&self.path).unwrap();
let conn = open(&self.path)?;
let mut stmt = conn.prepare(command)?;
stmt.query_row(args, |row| {
match row.get::<usize, i64>(0) {
@@ -34,14 +40,14 @@ impl SQLite {
})
}
pub fn lock_and_select_type<T: rusqlite::types::FromSql>(&self, command: &str, args: &[&dyn ToSql]) -> Result<T, rusqlite::Error> {
let conn = Connection::open(&self.path).unwrap();
let conn = open(&self.path)?;
let mut stmt = conn.prepare(command)?;
stmt.query_row(args, |row| {
row.get(0)
})
}
pub fn lock_and_select_all(&self, command: &str, args: &[&dyn ToSql]) -> Result<JsonValue, rusqlite::Error> {
let conn = Connection::open(&self.path).unwrap();
let conn = open(&self.path)?;
let mut stmt = conn.prepare(command)?;
let map = stmt.query_map(args, |row| {
match row.get::<usize, i64>(0) {
@@ -60,24 +66,11 @@ impl SQLite {
Ok(rv)
}
// Runs a read-modify-write as one unit. Additive on purpose — the other helpers open
// a fresh connection per statement, so a caller that SELECTs then INSERTs races any
// concurrent caller doing the same and the loser hits a constraint violation (which
// lock_and_exec would unwrap into a worker panic).
//
// BEGIN IMMEDIATE takes the write lock up front rather than at first write, so two
// callers serialise instead of both reading the pre-state; busy_timeout makes the
// loser wait for the winner rather than fail instantly (SQLite::new sets that on its
// own short-lived setup connection, not on the per-call ones).
//
// Errors are returned, never unwrapped: statistics writes must not take down a
// request.
pub fn lock_and_transact<T>(
&self,
f: impl FnOnce(&Connection) -> Result<T, rusqlite::Error>
) -> Result<T, rusqlite::Error> {
let mut conn = Connection::open(&self.path)?;
conn.busy_timeout(std::time::Duration::from_secs(10))?;
let mut conn = open(&self.path)?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let rv = f(&tx)?;
tx.commit()?;
+1 -1
View File
@@ -71,7 +71,7 @@ fn platform_guard(ctx: &guard::GuardContext) -> bool {
.split('/')
.nth(1)
.unwrap_or("");
matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "WebGL" | "iOS")
matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "StandaloneOSX" | "WebGL" | "iOS")
}
pub fn routes(cfg: &mut web::ServiceConfig) {
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+465
View File
@@ -0,0 +1,465 @@

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

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

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