Compare commits

12 Commits

Author SHA1 Message Date
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
Ethan O'Brien
0df2741251 Change log 2026-08-11 18:17:53 -05:00
Ethan O'Brien
cf05ffe8fb New asset hashes 2026-08-11 15:36:03 -05:00
Ethan O'Brien
27d7b8de82 Multi live 2026-08-11 14:41:28 -05:00
Ethan O'Brien
75613dafb0 Add some more checks 2026-08-05 20:34:17 -05:00
Ethan O'Brien
239a47b8ce New asset hashes 2026-08-02 16:41:31 -05:00
Ethan O'Brien
214ff68335 New asset hashes 2026-08-02 09:08:32 -05:00
54 changed files with 9324 additions and 177 deletions

6
.gitignore vendored
View File

@@ -12,3 +12,9 @@ ndk/
.DS_Store .DS_Store
custom_songs/ custom_songs/
custom_cards/ custom_cards/
/custom_3dmv/
# local-only trees — never commit (35GB between them)
/android/
/assets.bak/
/assets.old/

19
Cargo.lock generated
View File

@@ -223,6 +223,22 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "actix-ws"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f"
dependencies = [
"actix-codec",
"actix-http",
"actix-web",
"bytestring",
"futures-core",
"futures-sink",
"tokio",
"tokio-util",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -1073,6 +1089,7 @@ version = "1.1.0"
dependencies = [ dependencies = [
"actix-multipart", "actix-multipart",
"actix-web", "actix-web",
"actix-ws",
"aes", "aes",
"argon2", "argon2",
"base64", "base64",
@@ -1103,6 +1120,7 @@ dependencies = [
"sha1", "sha1",
"sha2", "sha2",
"symphonia", "symphonia",
"tokio",
"ureq", "ureq",
"urlencoding", "urlencoding",
"uuid", "uuid",
@@ -3209,6 +3227,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"futures-util",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]

View File

@@ -32,6 +32,10 @@ include_dir = "0.7.4"
jzon = "0.12.5" jzon = "0.12.5"
csv = "1.3" csv = "1.3"
actix-multipart = "0.8" actix-multipart = "0.8"
# Multi-live relay: WebSockets over actix-web 4 without the actor runtime
actix-ws = "0.4"
# tokio is already in the tree under actix-rt; only the mpsc channels are used
tokio = { version = "1", features = ["sync"] }
futures-util = "0.3" futures-util = "0.3"
image = "0.25" image = "0.25"
zip = { version = "8.6", default-features = false, features = ["deflate"] } zip = { version = "8.6", default-features = false, features = ["deflate"] }

View File

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

View File

@@ -15,6 +15,7 @@ args=(
[ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports) [ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports)
[ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs)
[ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards)
[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv)
add_opt() { add_opt() {
local value="$1" flag="$2" local value="$1" flag="$2"

View File

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

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"));
}
}

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

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

View File

@@ -257,6 +257,14 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
Some(if english && !name_en.is_empty() { name_en } else { name }) Some(if english && !name_en.is_empty() { name_en } else { name })
} }
// Whether the song exists and is publicly visible - what lets another
// uploader attach cross-feature content (a custom 3D MV) to it. The
// existence check comes first: get_visibility defaults to "public" for an
// absent row
pub fn song_publicly_visible(music_id: i64) -> bool {
get_song_owner(music_id).is_some() && get_visibility(music_id) == "public"
}
pub fn get_music_ids_for_user(user_id: i64) -> JsonValue { pub fn get_music_ids_for_user(user_id: i64) -> JsonValue {
DATABASE.lock_and_select_all(" DATABASE.lock_and_select_all("
SELECT music_id FROM songs SELECT music_id FROM songs
@@ -303,6 +311,14 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
rv rv
} }
// Every stored catalog blob, unparsed and unfiltered by visibility. Only the
// startup audio sweep needs the whole table, and it needs to tell "no songs"
// apart from "could not read the songs" - a read failure must never look like
// an empty catalog, so this returns None rather than an empty array on error
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 // Audio files are content-addressed and may be shared between songs
pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool { 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() 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()

View File

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

View File

@@ -35,10 +35,13 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
println!("Purged {} accounts", ct); println!("Purged {} accounts", ct);
} }
// One-time, idempotent: charts stored before the custom-song spawn-group pairing rule are
// regrouped in place and their catalog md5s refreshed, so clients re-download the fixed
// encoding. No-op when custom songs are disabled or nothing needs rewriting.
router::custom_song::migrate::run(); router::custom_song::migrate::run();
router::custom_song::sweep_audio();
router::custom_3dmv::sweep_blobs();
// The multi-live relay's expiry timers, on the system arbiter rather than on whichever
// 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::multi_live::start_sweeper();
let rv = HttpServer::new(|| App::new() let rv = HttpServer::new(|| App::new()
//.wrap(Cors::permissive()) //.wrap(Cors::permissive())

View File

@@ -44,6 +44,9 @@ pub struct Args {
#[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")] #[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")]
pub enable_custom_cards: bool, pub enable_custom_cards: bool,
#[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")]
pub enable_custom_3dmv: bool,
#[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")] #[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")]
pub owner: Vec<i64>, pub owner: Vec<i64>,

View File

@@ -10,6 +10,7 @@ pub mod home;
pub mod lottery; pub mod lottery;
pub mod friend; pub mod friend;
pub mod live; pub mod live;
pub mod multi_live;
pub mod event; pub mod event;
pub mod chat; pub mod chat;
pub mod story; pub mod story;
@@ -22,6 +23,7 @@ pub mod card;
pub mod shop; pub mod shop;
pub mod custom_song; pub mod custom_song;
pub mod custom_card; pub mod custom_card;
pub mod custom_3dmv;
pub mod rich_text; pub mod rich_text;
pub mod webui; pub mod webui;
pub mod clear_rate; pub mod clear_rate;
@@ -225,7 +227,6 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
} }
} else { } else {
match req.path() { match req.path() {
"/web/announcement" => web::announcement(req),
"/api/webui/userInfo" => webui::user(req), "/api/webui/userInfo" => webui::user(req),
"/live_clear_rate.html" => clear_rate::clearrate_html(req).await, "/live_clear_rate.html" => clear_rate::clearrate_html(req).await,
"/webui/logout" => webui::logout(req), "/webui/logout" => webui::logout(req),
@@ -239,6 +240,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse {
"/api/webui/listCharacters" => webui::list_characters(req), "/api/webui/listCharacters" => webui::list_characters(req),
"/api/webui/listSkillCenters" => webui::list_skill_centers(req), "/api/webui/listSkillCenters" => webui::list_skill_centers(req),
"/api/webui/customCardLimits" => webui::custom_card_limits(req), "/api/webui/customCardLimits" => webui::custom_card_limits(req),
"/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req),
"/api/webui/myScopes" => webui::my_scopes(req), "/api/webui/myScopes" => webui::my_scopes(req),
_ => api_req(req, body).await _ => api_req(req, body).await
} }
@@ -263,6 +265,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(chat::routes) .configure(chat::routes)
.configure(custom_song::routes) .configure(custom_song::routes)
.configure(custom_card::routes) .configure(custom_card::routes)
.configure(custom_3dmv::routes)
.configure(debug::routes) .configure(debug::routes)
.configure(event::routes) .configure(event::routes)
.configure(exchange::routes) .configure(exchange::routes)
@@ -274,6 +277,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(login::routes) .configure(login::routes)
.configure(lottery::routes) .configure(lottery::routes)
.configure(mission::routes) .configure(mission::routes)
.configure(multi_live::routes)
.configure(notice::routes) .configure(notice::routes)
.configure(purchase::routes) .configure(purchase::routes)
.configure(serial_code::routes) .configure(serial_code::routes)
@@ -290,4 +294,6 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
); );
cfg.configure(custom_song::web_routes); cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::web_routes); cfg.configure(custom_card::web_routes);
cfg.configure(custom_3dmv::web_routes);
cfg.configure(web::routes);
} }

View File

@@ -7,11 +7,33 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/chat") web::scope("/chat")
.route("/home", web::post().to(home)) .route("/home", web::post().to(home))
.route("/talk/get_stamp", web::get().to(get_stamp))
.route("/talk/start", web::post().to(start)) .route("/talk/start", web::post().to(start))
.route("/talk/end", web::post().to(end)) .route("/talk/end", web::post().to(end))
); );
} }
// The stamps this account owns. ew does not track stamp unlocks, so everyone has the
// masterdata initial set — the same list /chat/home reports, deliberately from one
// source so the two endpoints can never disagree.
fn owned_stamp_ids() -> JsonValue {
databases::INITIAL_CHAT_STAMPS.clone()
}
// GET /api/chat/talk/get_stamp (Protocol.send_get_stamp, FuncId.GET_STAMP).
// RecvGetStampRData carries a single `master_chat_stamp_ids` array, which its Notify()
// feeds to CallOnUpdateChatStampListNotify — the same sink RecvChatHomeRData uses, so
// the stamp picker ends up with whatever this returns. The client sends no parameters.
//
// This endpoint appears in neither official capture (0 hits across the 288MB JP and
// 1.4GB EN logs), so the shape is taken from the client class and the contents from the
// /chat/home captures that do exist and carry the same field.
async fn get_stamp(Login(_key): Login) -> impl Responder {
Api(Some(object!{
"master_chat_stamp_ids": owned_stamp_ids()
}))
}
pub fn add_chat(id: i64, num: i64, chats: &mut JsonValue) -> bool { pub fn add_chat(id: i64, num: i64, chats: &mut JsonValue) -> bool {
for data in chats.members() { for data in chats.members() {
if data["chat_id"] == id && data["room_id"] == num { if data["chat_id"] == id && data["room_id"] == num {
@@ -48,7 +70,7 @@ async fn home(Login(key): Login) -> impl Responder {
Api(Some(object!{ Api(Some(object!{
"progress_list": chats, "progress_list": chats,
"master_chat_room_ids": rooms, "master_chat_room_ids": rooms,
"master_chat_stamp_ids": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,43,44,45,46,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,11001003,22001001,33001001,44001002], "master_chat_stamp_ids": owned_stamp_ids(),
"master_chat_attachment_ids": [] "master_chat_attachment_ids": []
})) }))
} }
@@ -75,3 +97,41 @@ async fn end(Session { key, body }: Session) -> impl Responder {
Api(Some(array![])) Api(Some(array![]))
} }
#[cfg(test)]
mod tests {
use super::*;
// 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
// get_stamp serve masterdata instead of a hardcoded literal.
const OFFICIAL_INITIAL_STAMPS: [i64; 97] = [
1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,25,26,27,28,29,30,
31,32,33,34,35,36,37,38,39,40,41,43,44,45,46,48,49,50,51,52,53,54,55,56,57,58,
59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,
85,86,87,88,89,90,91,92,93,94,95,96,11001003,22001001,33001001,44001002
];
#[test]
fn the_initial_stamp_set_matches_official() {
let ids: Vec<i64> = owned_stamp_ids().members().map(|s| s.as_i64().unwrap()).collect();
assert_eq!(ids, OFFICIAL_INITIAL_STAMPS.to_vec());
// Order matters: the official list is masterdata order, not sorted (the 11xxxxxx
// band trails the small ids).
assert_eq!(ids.first(), Some(&1));
assert_eq!(ids.last(), Some(&44001002));
// The gaps are real - 18, 42 and 47 are not initial stamps.
for missing in [18, 42, 47] {
assert!(!ids.contains(&missing), "{missing} should not be an initial stamp");
}
}
#[test]
fn get_stamp_and_chat_home_cannot_disagree() {
// Both endpoints read the one source; this is what stops /chat/home's list and
// the stamp picker's list from drifting apart.
assert_eq!(owned_stamp_ids(), *databases::INITIAL_CHAT_STAMPS);
assert!(!owned_stamp_ids().is_empty());
}
}

View File

@@ -68,14 +68,14 @@ fn setup_tables(conn: &rusqlite::Connection) {
);").unwrap(); );").unwrap();
} }
fn update_live_score(id: i64, uid: i64, score: i64) { // Merges this play into the song's top-10 board. Pure so the whole read-modify-write can
if uid == 0 || score == 0 { // sit inside one transaction, and so the keep-best rule is testable on its own.
return; //
} // The board is per-song, not per-account: `scores` is keyed by live_id alone and the user
// lives inside the JSON blob. A user already on the board keeps whichever of their two
let info = DATABASE.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(id)).unwrap_or(String::from("[]")); // scores is higher — a replay that does not beat the stored one is dropped (`None`), which
let scores = jzon::parse(&info).unwrap(); // is what makes a repeated or duplicated end idempotent rather than additive.
fn merge_live_score(scores: &JsonValue, uid: i64, score: i64) -> Option<JsonValue> {
let mut result = array![]; let mut result = array![];
let mut current = 0; let mut current = 0;
let mut added = false; let mut added = false;
@@ -99,7 +99,8 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
} }
} }
if scores[i]["user"].as_i64().unwrap() == uid && !added { if scores[i]["user"].as_i64().unwrap() == uid && !added {
return; // Already on the board with a better score — keep it, drop this play.
return None;
} }
if scores[i]["user"].as_i64().unwrap() == uid { if scores[i]["user"].as_i64().unwrap() == uid {
continue; continue;
@@ -107,13 +108,41 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
result.push(scores[i].clone()).unwrap(); result.push(scores[i].clone()).unwrap();
current += 1; current += 1;
} }
if added { if added { Some(result) } else { None }
if DATABASE.lock_and_select("SELECT live_id FROM scores WHERE live_id=?1", params!(id)).is_ok() { }
DATABASE.lock_and_exec("UPDATE scores SET score_data=?1 WHERE live_id=?2", params!(jzon::stringify(result), id));
} else { fn update_live_score(id: i64, uid: i64, score: i64) {
DATABASE.lock_and_exec("INSERT INTO scores (score_data, live_id) VALUES (?1, ?2)", params!(jzon::stringify(result), id)); if uid == 0 || score == 0 {
} return;
}
// One transaction for read + merge + write. Previously this SELECTed to choose between
// UPDATE and INSERT on separate connections, so two ends landing together for a song
// with no row yet both took the INSERT branch and the loser panicked the worker on
// `UNIQUE constraint failed: scores.live_id`. Two clients finishing the same multi
// live make that the normal case, not a rare race.
let write = DATABASE.lock_and_transact(|conn| {
let stored: String = conn
.query_row("SELECT score_data FROM scores WHERE live_id=?1", params!(id), |row| row.get(0))
.unwrap_or_else(|_| String::from("[]"));
let scores = jzon::parse(&stored).unwrap_or_else(|_| array![]);
let Some(result) = merge_live_score(&scores, uid, score) else {
return Ok(());
};
// Atomic upsert: no branch left for a concurrent writer to slip between.
conn.execute(
"INSERT INTO scores (live_id, score_data) VALUES (?1, ?2)
ON CONFLICT(live_id) DO UPDATE SET score_data=excluded.score_data",
params!(id, jzon::stringify(result))
)?;
Ok(())
});
if let Err(e) = write {
println!("Failed to record score for live {id}: {e}");
} }
} }
@@ -129,27 +158,44 @@ pub fn invalidate_cache() {
crate::lock_onto_mutex!(CACHED_HTML_DATA).take(); crate::lock_onto_mutex!(CACHED_HTML_DATA).take();
} }
// The clear-rate counter column this play lands in, or None for a level outside 1-4.
// Names come from this closed set, never from request data, so it is safe to interpolate.
fn clear_rate_column(level: i32, failed: bool) -> Option<&'static str> {
let tier = match level {
1 => "normal",
2 => "hard",
3 => "expert",
4 => "master",
_ => return None
};
Some(match (tier, failed) {
("normal", true) => "normal_failed", ("normal", false) => "normal_pass",
("hard", true) => "hard_failed", ("hard", false) => "hard_pass",
("expert", true) => "expert_failed", ("expert", false) => "expert_pass",
(_, true) => "master_failed", (_, false) => "master_pass"
})
}
pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) { pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
update_live_score(id, uid, score); update_live_score(id, uid, score);
match DATABASE.get_live_data(id) {
Ok(info) => { let Some(column) = clear_rate_column(level, failed) else {
let value = format!("{}_{}", return;
if 1 == level { "normal" } else if 2 == level { "hard" } else if 3 == level { "expert" } else { "master" }, };
if failed { "failed" } else { "pass" }
); // `lives` is keyed by live_id alone and had the same select-then-INSERT-or-UPDATE
let new_info = if 1 == level && failed { info.normal_failed } // split as `scores`, so it could panic the same way on a first-ever concurrent play
else if 1 == level && !failed { info.normal_pass } // (and lost counts whenever two plays overlapped). One upsert does both branches
else if 2 == level && failed { info.hard_failed } // atomically and increments in SQL rather than read-modify-write in Rust.
else if 2 == level && !failed { info.hard_pass } let write = DATABASE.lock_and_transact(|conn| {
else if 3 == level && failed { info.expert_failed } conn.execute(
else if 3 == level && !failed { info.expert_pass } &format!(
else if 4 == level && failed { info.master_failed } "INSERT INTO lives (live_id, normal_failed, normal_pass, hard_failed, hard_pass,
else if 4 == level && !failed { info.master_pass } else { return; }; expert_failed, expert_pass, master_failed, master_pass)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
DATABASE.lock_and_exec(&format!("UPDATE lives SET {}=?1 WHERE live_id=?2", value), params!(new_info + 1, info.live_id)); ON CONFLICT(live_id) DO UPDATE SET {column} = {column} + 1"
}, ),
Err(_) => { params!(
DATABASE.lock_and_exec("INSERT INTO lives (live_id, normal_failed, normal_pass, hard_failed, hard_pass, expert_failed, expert_pass, master_failed, master_pass) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params!(
id, id,
if 1 == level && failed { 1 } else { 0 }, if 1 == level && failed { 1 } else { 0 },
if 1 == level && !failed { 1 } else { 0 }, if 1 == level && !failed { 1 } else { 0 },
@@ -159,9 +205,14 @@ pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
if 3 == level && !failed { 1 } else { 0 }, if 3 == level && !failed { 1 } else { 0 },
if 4 == level && failed { 1 } else { 0 }, if 4 == level && failed { 1 } else { 0 },
if 4 == level && !failed { 1 } else { 0 } if 4 == level && !failed { 1 } else { 0 }
)); )
}, )?;
}; Ok(())
});
if let Err(e) = write {
println!("Failed to record clear rate for live {id}: {e}");
}
} }
fn get_song_title(live_id: i32, english: bool) -> String { fn get_song_title(live_id: i32, english: bool) -> String {
@@ -407,3 +458,107 @@ pub async fn clearrate_html(_req: HttpRequest) -> HttpResponse {
.content_type(ContentType::html()) .content_type(ContentType::html())
.body(html) .body(html)
} }
#[cfg(test)]
mod tests {
use super::*;
fn board(live_id: i64) -> JsonValue {
let stored = DATABASE
.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(live_id))
.unwrap_or_else(|_| String::from("[]"));
jzon::parse(&stored).unwrap()
}
fn passes(live_id: i64) -> i64 {
DATABASE.get_live_data(live_id).map(|l| l.master_pass).unwrap_or(0)
}
#[test]
fn merge_keeps_the_users_best_score() {
let existing = array![object!{user: 7, score: 900}];
// A better score replaces the stored one rather than adding a second entry.
let better = merge_live_score(&existing, 7, 1000).expect("a better score is recorded");
assert_eq!(better.len(), 1);
assert_eq!(better[0]["score"].as_i64(), Some(1000));
// A worse or equal replay is dropped entirely — this is what makes a repeated
// end idempotent instead of appending the same user twice.
assert!(merge_live_score(&existing, 7, 800).is_none());
assert!(merge_live_score(&existing, 7, 900).is_none());
// A different user is ranked against the board, best first.
let other = merge_live_score(&existing, 8, 950).expect("a new user is recorded");
assert_eq!(other.len(), 2);
assert_eq!(other[0]["user"].as_i64(), Some(8));
assert_eq!(other[1]["user"].as_i64(), Some(7));
}
#[test]
fn a_duplicate_end_does_not_double_the_board_entry() {
let _lock = crate::runtime::lock_test_data_path();
let live_id = 990001;
live_completed(live_id, 4, false, 500000, 4242);
assert_eq!(board(live_id).len(), 1);
assert_eq!(passes(live_id), 1);
// The second end for the same session: same user, same score. The board must not
// grow, and this must not raise UNIQUE constraint failed: scores.live_id.
live_completed(live_id, 4, false, 500000, 4242);
assert_eq!(board(live_id).len(), 1, "the same user must not appear twice");
assert_eq!(board(live_id)[0]["score"].as_i64(), Some(500000));
}
// The actual regression: two ends for a song with no row yet, landing together. Both
// used to take the INSERT branch and the loser unwrapped a ConstraintViolation into a
// worker panic. Two clients finishing one multi live makes this the normal case.
#[test]
fn concurrent_first_plays_of_one_song_do_not_collide() {
let _lock = crate::runtime::lock_test_data_path();
let live_id = 990002;
std::thread::scope(|s| {
for uid in [101i64, 102, 103, 104, 105, 106, 107, 108] {
s.spawn(move || live_completed(live_id, 4, false, 400000 + uid, uid));
}
});
// Every writer landed: no row lost to a race, none lost to a swallowed error.
assert_eq!(board(live_id).len(), 8);
assert_eq!(passes(live_id), 8, "clear-rate counts must not be lost either");
}
// The other half of /multi_live/end's score-board branch (the account's own high score
// is pinned in live.rs): a public multi live reaches live_completed with uid 0, so the
// play is counted and the board is left alone. /live/retire has always used the same
// signal for a failed live.
#[test]
fn a_play_with_no_user_counts_the_clear_but_not_the_board() {
let _lock = crate::runtime::lock_test_data_path();
let live_id = 990003;
live_completed(live_id, 4, false, 500000, 0);
assert_eq!(board(live_id).len(), 0, "an unranked play must not reach the board");
assert_eq!(passes(live_id), 1, "but it is still a play of the song");
// The same score from a real account does land, which is the private-room path.
live_completed(live_id, 4, false, 500000, 4243);
assert_eq!(board(live_id).len(), 1);
assert_eq!(passes(live_id), 2);
}
#[test]
fn clear_rate_columns_cover_every_level() {
assert_eq!(clear_rate_column(1, false), Some("normal_pass"));
assert_eq!(clear_rate_column(1, true), Some("normal_failed"));
assert_eq!(clear_rate_column(2, false), Some("hard_pass"));
assert_eq!(clear_rate_column(3, true), Some("expert_failed"));
assert_eq!(clear_rate_column(4, false), Some("master_pass"));
assert_eq!(clear_rate_column(4, true), Some("master_failed"));
// Level 0 (a skip ticket's "any level") writes no counter, as before.
assert_eq!(clear_rate_column(0, false), None);
assert_eq!(clear_rate_column(5, false), None);
}
}

1504
src/router/custom_3dmv.rs Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -152,6 +152,18 @@ pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue {
database::non_public_music_ids_for(uid) database::non_public_music_ids_for(uid)
} }
// Whether `uid` may attach cross-feature content (a custom 3D MV) to this
// song: it must exist, and be theirs or publicly visible. Mirrors
// custom_card::validate_character_ref
pub fn can_reference_song(uid: i64, music_id: i64) -> Result<(), String> {
if !disabled()
&& database::get_song_owner(music_id).is_some()
&& (database::get_song_owner(music_id) == Some(uid) || database::song_publicly_visible(music_id)) {
return Ok(());
}
Err(format!("Unknown music_id '{}'", music_id))
}
fn song_path(music_id: i64, file: &str) -> String { fn song_path(music_id: i64, file: &str) -> String {
get_data_path(&format!("custom_songs/{}/{}", music_id, file)) get_data_path(&format!("custom_songs/{}/{}", music_id, file))
} }
@@ -160,6 +172,68 @@ fn audio_file_path(md5: &str) -> String {
get_data_path(&format!("custom_songs/audio/{}.ogg", md5)) get_data_path(&format!("custom_songs/audio/{}.ogg", md5))
} }
// Startup GC for the content-addressed audio store. Every ogg in there belongs
// to some song's play or select cue; the only things that write the directory
// are upload and update, so an unreferenced file is a leftover from an
// interrupted one. Deleting it loses nothing - the same audio uploaded again
// re-encodes to the same bytes at the same path (audio.rs).
//
// Deliberately conservative, because the failure mode is deleting audio a live
// song serves: anything that makes the reference set doubtful - an unreadable
// catalog, a blob that won't parse, a song with no cue md5 - aborts the whole
// sweep instead of treating that song as referencing nothing. Only exactly
// {32 hex}.ogg names are ever considered. Takes UPLOAD_LOCK for the same
// reason delete does: an upload that has written its oggs but not yet inserted
// its row must not look like an orphan.
pub fn sweep_audio() {
if disabled() {
return;
}
let lock = lock_onto_mutex!(UPLOAD_LOCK);
let Some(blobs) = database::all_song_blobs() else {
println!("Custom song audio sweep: catalog unreadable, skipped");
return;
};
let mut referenced: Vec<String> = Vec::new();
for blob in blobs.members() {
let Ok(song) = jzon::parse(&blob.to_string()) else {
println!("Custom song audio sweep: unparseable catalog row, skipped");
return;
};
for key in ["play", "select"] {
let md5 = song["sound"][key]["md5"].as_str().unwrap_or("");
if md5.is_empty() {
println!("Custom song audio sweep: song {} has no {} cue, skipped", song["music_id"], key);
return;
}
referenced.push(String::from(md5));
}
}
// No directory means nothing was ever uploaded
let Ok(entries) = fs::read_dir(get_data_path("custom_songs/audio")) else {
return;
};
let mut removed = 0;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Some(md5) = name.strip_suffix(".ogg") else { continue; };
if md5.len() != 32 || !md5.chars().all(|c| c.is_ascii_hexdigit()) {
continue;
}
if referenced.iter().any(|other| other == md5) {
continue;
}
if fs::remove_file(entry.path()).is_ok() {
removed += 1;
}
}
if removed > 0 {
println!("Custom song audio sweep: removed {} orphaned ogg(s)", removed);
}
drop(lock);
}
async fn assets(req: HttpRequest) -> HttpResponse { async fn assets(req: HttpRequest) -> HttpResponse {
if disabled() { if disabled() {
return HttpResponse::NotFound().finish(); return HttpResponse::NotFound().finish();
@@ -1063,11 +1137,18 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
} }
let song = database::get_song(music_id).unwrap_or(object!{}); let song = database::get_song(music_id).unwrap_or(object!{});
// Same lock upload and update take, for the audio GC below: an upload
// writes its oggs before it inserts its row, so a delete that reads the
// catalog in that window sees no reference to a shared md5 and would
// unlink the file the new song is about to serve
let lock = lock_onto_mutex!(UPLOAD_LOCK);
database::delete_song(music_id); database::delete_song(music_id);
database::bump_revision(); database::bump_revision();
// Global clear-rate stats for the dead live id (per-user score records are // Global clear-rate stats for the dead live id (per-user score records are
// wiped lazily on each user's next userdata pull) // wiped lazily on each user's next userdata pull)
crate::router::clear_rate::purge_live(music_id); crate::router::clear_rate::purge_live(music_id);
// A custom 3D MV can't outlive the song it plays over
crate::router::custom_3dmv::purge_song(music_id);
let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id))); let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id)));
// Audio is content-addressed and may be shared with another upload // Audio is content-addressed and may be shared with another upload
@@ -1077,6 +1158,7 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
let _ = fs::remove_file(audio_file_path(&md5)); let _ = fs::remove_file(audio_file_path(&md5));
} }
} }
drop(lock);
send_json(object!{ send_json(object!{
result: "OK" result: "OK"
@@ -1643,6 +1725,60 @@ mod tests {
assert!(fs::read(audio_file_path(&before["sound"]["select"]["md5"].to_string())).is_err()); assert!(fs::read(audio_file_path(&before["sound"]["select"]["md5"].to_string())).is_err());
} }
// The startup sweep drops oggs nothing references any more (an upload that
// died between writing the file and storing its row), keeps every cue that
// is referenced - including one shared by two songs - and leaves anything
// that isn't a content-addressed ogg alone
#[test]
fn audio_sweep_removes_only_orphans() {
let _lock = crate::runtime::lock_test_data_path();
let mut fields = HashMap::new();
field(&mut fields, "name", "Sweep Song");
field(&mut fields, "artist", "Sweep Artist");
field(&mut fields, "attribute", "1");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg_tone(311.0));
fields.insert(String::from("chart_1"), test_chart());
let music_id = create_song(4242, &fields).unwrap();
// A second song on the SAME audio: identical bytes hash to one shared file
let shared_id = create_song(4243, &fields).unwrap();
let song = database::get_song(music_id).unwrap();
let play = song["sound"]["play"]["md5"].to_string();
let select = song["sound"]["select"]["md5"].to_string();
assert_eq!(database::get_song(shared_id).unwrap()["sound"]["play"]["md5"], play);
let orphan = audio_file_path(&"a1".repeat(16));
let not_an_ogg = get_data_path("custom_songs/audio/notes.txt");
let bad_name = get_data_path("custom_songs/audio/orphan.ogg");
for path in [&orphan, &not_an_ogg, &bad_name] {
fs::write(path, b"junk").unwrap();
}
sweep_audio();
assert!(fs::read(&orphan).is_err());
assert!(fs::read(audio_file_path(&play)).is_ok());
assert!(fs::read(audio_file_path(&select)).is_ok());
// Only {32 hex}.ogg is ever a sweep candidate
assert!(fs::read(&not_an_ogg).is_ok());
assert!(fs::read(&bad_name).is_ok());
// A song whose cues can't be read makes the whole sweep bail rather
// than treat that song as referencing nothing
fs::write(&orphan, b"junk").unwrap();
let shared_song = database::get_song(shared_id).unwrap();
database::update_song(shared_id, &object!{"music_id": shared_id});
sweep_audio();
assert!(fs::read(&orphan).is_ok());
assert!(fs::read(audio_file_path(&play)).is_ok());
database::update_song(shared_id, &shared_song);
let _ = fs::remove_file(&orphan);
let _ = fs::remove_file(&not_an_ogg);
let _ = fs::remove_file(&bad_name);
}
// mp3/wav uploads still work: symphonia decodes them and the play cue is // mp3/wav uploads still work: symphonia decodes them and the play cue is
// transcoded to ogg-vorbis in-process // transcoded to ogg-vorbis in-process
#[test] #[test]

View File

@@ -302,6 +302,57 @@ lazy_static! {
info info
}; };
// const.csv keyed by _id. Values are strings in masterdata, exactly as the
// client reads them (ConstMst._value + StringExtensions.ToIntOrDefault).
pub static ref CONST: JsonValue = index_by(&t("const"), "id");
pub static ref LIVE_BOOST: JsonValue = index_by(&t("live_boost"), "value");
// The stamps every account starts with (chat_stamp._initialStamp), in masterdata
// order. Officially this is the whole of a fresh account's master_chat_stamp_ids —
// captured /api/chat/home responses open with exactly this list before an account's
// earned stamps are appended (see chat::tests::the_initial_stamp_set_matches_official).
pub static ref INITIAL_CHAT_STAMPS: JsonValue = {
let mut ids = array![];
for data in t("chat_stamp").members() {
if data["initialStamp"].as_i64().unwrap_or(0) == 1 {
ids.push(data["id"].clone()).unwrap();
}
}
ids
};
pub static ref EVENTS: JsonValue = index_by(&t("event"), "id");
// release_label.csv keyed by _id — the open/close window masterdata rows are gated
// on. _openedAt / _closedAt are blank for the evergreen label (id 1).
pub static ref RELEASE_LABEL: JsonValue = index_by(&t("release_label"), "id");
// event_score.csv keyed by _masterEventId (Shock.EventScoreMst) — the per-event
// event-point yield of one live. Ratios are 1/10000, like every other ratio the
// client divides by COMMON_CONST.RATIO_DIVISOR.
pub static ref EVENT_SCORE: JsonValue = index_by(&t("event_score"), "masterEventId");
// music_level rows keyed "{masterMusicId}_{level}" — _fullCombo is the note
// count the multi-live miss/great-perfect ratios are measured against.
pub static ref MUSIC_LEVEL: JsonValue = {
let mut info = object! {};
for data in t("music_level").members() {
info[format!("{}_{}", data["masterMusicId"], data["level"])] = data.clone();
}
info
};
// multievent_rankbonus keyed "{playerCount}_{liveRank}" — _eventPtBonus is a
// ratio in 1/10000 (the client renders these as `sum / 100` percent).
pub static ref MULTIEVENT_RANK_BONUS: JsonValue = {
let mut info = object! {};
for data in t("multievent_rankbonus").members() {
info[format!("{}_{}", data["playerCount"], data["liveRank"])] = data.clone();
}
info
};
pub static ref RANKS: JsonValue = t("user_rank"); pub static ref RANKS: JsonValue = t("user_rank");
pub static ref USER_RANK_REWARD: JsonValue = { pub static ref USER_RANK_REWARD: JsonValue = {

View File

@@ -25,7 +25,39 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
); );
} }
fn get_event_data(key: &str, event_id: u32) -> JsonValue { // Whether a release_label window is open at `now`. A blank _openedAt has always been
// open and a blank _closedAt never closes, which is how the evergreen label (id 1) is
// expressed. _releaseStatus other than 1 is never released at all.
pub fn release_label_is_open(label_id: i64, now: u64) -> bool {
let label = &databases::RELEASE_LABEL[label_id.to_string()];
if label.is_empty() || label["releaseStatus"].as_i64().unwrap_or(0) != 1 {
return false;
}
if let Some(opened) = global::parse_datetime(&label["openedAt"].to_string()) {
if now < opened {
return false;
}
}
if let Some(closed) = global::parse_datetime(&label["closedAt"].to_string()) {
if now > closed {
return false;
}
}
true
}
// Whether an event is currently running, by its own release label window. ew had no
// in-session test before this — nothing else evaluates release_label — so this is the
// one place to extend if the event listing ever needs the same question answered.
pub fn is_in_session(event_id: u32, now: u64) -> bool {
let event = &databases::EVENTS[event_id.to_string()];
if event.is_empty() {
return false;
}
release_label_is_open(event["masterReleaseLabelId"].as_i64().unwrap_or(0), now)
}
pub fn get_event_data(key: &str, event_id: u32) -> JsonValue {
let mut event = userdata::get_acc_event(key); let mut event = userdata::get_acc_event(key);
let is_star_event = STAR_EVENT_IDS.contains(&event_id); let is_star_event = STAR_EVENT_IDS.contains(&event_id);
//println!("is_star_event: {}, {}", is_star_event, event_id); //println!("is_star_event: {}, {}", is_star_event, event_id);
@@ -50,10 +82,61 @@ fn get_event_data(key: &str, event_id: u32) -> JsonValue {
event[event_id.to_string()]["star_event"]["star_event_bonus_daily_count"] = 0.into(); event[event_id.to_string()]["star_event"]["star_event_bonus_daily_count"] = 0.into();
} }
normalise_event_shape(&mut event[event_id.to_string()]);
event[event_id.to_string()].clone() event[event_id.to_string()].clone()
} }
fn save_event_data(key: &str, event_id: u32, data: JsonValue) { // The client's /api/event response types are [Serializable] C# classes, and Unity's
// JsonUtility maps them structurally: `score_ranking`, `member_ranking` and `lottery_box`
// are OBJECTS (Shock.ProtocolData.ScoreRanking / MemberRanking / LotteryBox), not arrays.
// new_user_event.json used to seed them as `[]`, which the client cannot bind - and
// MngEventData.SendGetEvent's success callback dereferences `r.data.score_ranking` and
// friends unguarded, so a mis-shaped payload takes out the whole recv and its `onComplete`
// is never called. That callback is the completion of a GATING TaskFlow.Step in
// LiveRestartSelectScene.ReloadScene, so the scene hangs on its loading screen forever.
//
// The template is fixed, but accounts created before that keep their stored blob, so the
// shapes are normalised on the way out as well. Coercion only ever replaces a value the
// client could not have parsed anyway; a well-formed object is left exactly as it is.
// `set_member` already writes member_ranking as an object, which is the shape this agrees
// with.
fn normalise_event_shape(event: &mut JsonValue) {
fn ensure_object(slot: &mut JsonValue, template: JsonValue) {
if !slot.is_object() {
*slot = template;
} else {
for (key, value) in template.entries() {
if slot[key].is_null() {
slot[key] = value.clone();
}
}
}
}
ensure_object(&mut event["point_ranking"], object! { rank: 0, point: 0 });
ensure_object(&mut event["score_ranking"], object! { all_rank: 0, group_rank: 0, score: 0 });
ensure_object(
&mut event["member_ranking"],
object! { master_character_id: 0, rank: 0, point: 0 },
);
ensure_object(
&mut event["lottery_box"],
object! { master_lottery_id: 0, reset_count: 0, draw_count_list: array![] },
);
// Read by EventData.SetMultiParameter (help count, penalty window, disconnect flag);
// absent keys bind as 0/false, but sending them keeps the payload self-describing.
for key in ["is_disconnected", "help_count", "penalty_remaining_time"] {
if event[key].is_null() {
event[key] = 0.into();
}
}
if !event["mission_list"].is_array() {
event["mission_list"] = array![];
}
}
pub fn save_event_data(key: &str, event_id: u32, data: JsonValue) {
let mut event = userdata::get_acc_event(key); let mut event = userdata::get_acc_event(key);
// Check for old version of event data // Check for old version of event data
@@ -209,7 +292,7 @@ async fn set_member(Session { key, body }: Session) -> impl Responder {
})) }))
} }
fn get_rank(event: u32, user_id: u64) -> u32 { pub fn get_rank(event: u32, user_id: u64) -> u32 {
let scores = crate::router::event_ranking::get_raw_info(event); let scores = crate::router::event_ranking::get_raw_info(event);
let mut i=1; let mut i=1;
@@ -254,10 +337,14 @@ fn get_star_rank(points: i64) -> i64 {
const LIMIT_COINS: i64 = 2000000000; const LIMIT_COINS: i64 = 2000000000;
fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool { // The row is keyed by BOTH the event and the point type, exactly as get_points reads it
// back. Matching on the type alone credited whichever event's row happened to come first
// in the list — an account that had ever played a different event got its points there,
// and get_points (which does check the id) then reported 0 for the event just played.
pub fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
let mut has = false; let mut has = false;
for data in user["event_point_list"].members_mut() { for data in user["event_point_list"].members_mut() {
if data["type"] == 1 { if data["type"] == 1 && data["master_event_id"] == event_id {
has = true; has = true;
let new_amount = data["amount"].as_i64().unwrap() + amount; let new_amount = data["amount"].as_i64().unwrap() + amount;
if new_amount > LIMIT_COINS { if new_amount > LIMIT_COINS {
@@ -278,7 +365,7 @@ fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
false false
} }
fn get_points(event_id: u32, user: &JsonValue) -> i64 { pub fn get_points(event_id: u32, user: &JsonValue) -> i64 {
for data in user["event_point_list"].members() { for data in user["event_point_list"].members() {
if data["type"] == 1 && data["master_event_id"] == event_id { if data["type"] == 1 && data["master_event_id"] == event_id {
return data["amount"].as_i64().unwrap() return data["amount"].as_i64().unwrap()
@@ -366,3 +453,96 @@ async fn event_end(req: HttpRequest, Session { key, body }: Session) -> impl Res
async fn event_skip(req: HttpRequest, Session { key, body }: Session) -> impl Responder { async fn event_skip(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
Api(event_live(&req, &key, &body, true)) Api(event_live(&req, &key, &body, true))
} }
#[cfg(test)]
mod tests {
use super::*;
// The client binds these with Unity's JsonUtility against [Serializable] classes:
// ScoreRanking { all_rank, group_rank, score }, MemberRanking { master_character_id,
// rank, point }, PointRanking { rank, point }, LotteryBox { master_lottery_id,
// reset_count, draw_count_list }. An array where an object is expected cannot bind, and
// MngEventData.SendGetEvent's callback dereferences them unguarded.
#[test]
fn the_new_user_template_already_has_the_client_shapes() {
let template: JsonValue =
jzon::parse(&include_file!("src/router/userdata/new_user_event.json")).unwrap();
for key in ["point_ranking", "score_ranking", "member_ranking", "lottery_box"] {
assert!(template[key].is_object(), "{} must be an object", key);
}
assert!(template["mission_list"].is_array());
assert_eq!(template["score_ranking"]["all_rank"], 0);
assert_eq!(template["member_ranking"]["master_character_id"], 0);
assert_eq!(template["lottery_box"]["draw_count_list"], array![]);
}
#[test]
fn stored_blobs_with_the_old_array_shapes_are_coerced() {
// Exactly what accounts created before the template fix have on disk.
let mut stored = object! {
point_ranking: object! { point: 12 },
score_ranking: array![],
member_ranking: array![],
lottery_box: array![],
mission_list: array![],
};
normalise_event_shape(&mut stored);
assert!(stored["score_ranking"].is_object());
assert_eq!(stored["score_ranking"]["all_rank"], 0);
assert!(stored["member_ranking"].is_object());
assert!(stored["lottery_box"].is_object());
assert_eq!(stored["lottery_box"]["draw_count_list"], array![]);
// A key that was already there survives; the missing sibling is filled in.
assert_eq!(stored["point_ranking"]["point"], 12);
assert_eq!(stored["point_ranking"]["rank"], 0);
// The multi trio EventData.SetMultiParameter reads.
assert_eq!(stored["is_disconnected"], 0);
assert_eq!(stored["help_count"], 0);
assert_eq!(stored["penalty_remaining_time"], 0);
}
#[test]
fn well_formed_data_is_left_alone() {
let mut live = object! {
point_ranking: object! { rank: 3, point: 900 },
score_ranking: object! { all_rank: 7, group_rank: 2, score: 4242 },
member_ranking: object! { master_character_id: 5, rank: 1, point: 10 },
lottery_box: object! { master_lottery_id: 8, reset_count: 1, draw_count_list: array![] },
mission_list: array![],
is_disconnected: 1,
help_count: 4,
penalty_remaining_time: 600,
};
let before = live.clone();
normalise_event_shape(&mut live);
assert_eq!(live, before);
}
// give_event_points and get_points must agree on what identifies a row. They did not:
// the write matched on the point type alone, so an account that had ever earned points
// in ANY event credited every later event to that first row — and get_points, which
// does compare the event id, then reported 0 for the event actually played.
#[test]
fn event_points_land_in_the_row_for_that_event() {
let mut user = object!{ event_point_list: array![] };
give_event_points(108, 35, &mut user);
assert_eq!(get_points(108, &user), 35);
// A second event opens a row of its own and leaves the first one alone.
give_event_points(111, 70, &mut user);
assert_eq!(get_points(111, &user), 70);
assert_eq!(get_points(108, &user), 35);
assert_eq!(user["event_point_list"].len(), 2);
// And a second live in the first event still adds to the first row.
give_event_points(108, 35, &mut user);
assert_eq!(get_points(108, &user), 70);
assert_eq!(get_points(111, &user), 70);
assert_eq!(user["event_point_list"].len(), 2);
// An event never played is worth nothing, not somebody else's total.
assert_eq!(get_points(115, &user), 0);
}
}

View File

@@ -23,18 +23,32 @@ struct AssetVersion {
static ASSET_VERSIONS: &[AssetVersion] = &[ static ASSET_VERSIONS: &[AssetVersion] = &[
// Default / stock // Default / stock
AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c", latest: true }, AssetVersion { region: "JP", platform: "Android", version: "4c921d2443335e574a82e04ec9ea243c", hash: "67f8f261c16b3cca63e520a25aad6c1c", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16", latest: true }, AssetVersion { region: "JP", platform: "iOS", version: "4c921d2443335e574a82e04ec9ea243c", hash: "b8975be8300013a168d061d3fdcd4a16", latest: false },
AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b", latest: true }, AssetVersion { region: "GL", platform: "Android", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "d210b28037885f3ef56b8f8aa45ac95b", latest: false },
AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d", latest: true }, AssetVersion { region: "GL", platform: "iOS", version: "5260ff15dff8ba0c00ad91400f515f55", hash: "dd7175e4bcdab476f38c33c7f34b5e4d", latest: false },
// Re-written client versions 2.0.0 - 2.1.2 (windows only) // Re-written client versions 2.0.0 - 2.1.2 (windows only)
AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "4ed1d077df2d1b29e17d25d64fb37242", latest: false }, AssetVersion { region: "JP", platform: "Windows", version: "4c921d2443335e574a82e04ec9ea243c", hash: "4ed1d077df2d1b29e17d25d64fb37242", latest: false },
// Re-written client versions 2.2.0 - // Re-written client versions 2.2.0 - 2.2.2
AssetVersion { region: "JP", platform: "Windows", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "ec508163f04e0f9e0435b4302011d123", latest: true }, AssetVersion { region: "JP", platform: "Windows", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "ec508163f04e0f9e0435b4302011d123", latest: false },
AssetVersion { region: "JP", platform: "Android", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "27eabc1af04fa6e4a727516d2bbdadff", latest: false }, AssetVersion { region: "JP", platform: "Android", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "27eabc1af04fa6e4a727516d2bbdadff", latest: false },
AssetVersion { region: "JP", platform: "iOS", version: "ced44f266b4e4c8eb05fe417fd5f3d1b", hash: "43e2337da344296964aec6c474fb0add", latest: false },
// Re-written client versions 2.3.0 - 2.3.2
AssetVersion { region: "JP", platform: "Windows", version: "1b2fe99a639b4ca491afe1a841f19d56", hash: "fd37b607ca271a6ffe17b1e2046c45ad", latest: false },
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 - 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: true },
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: true },
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true }, //AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
]; ];
@@ -285,6 +299,48 @@ pub fn format_datetime(time: u64) -> String {
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, secs / 3600, (secs % 3600) / 60, secs % 60) format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, secs / 3600, (secs % 3600) / 60, secs % 60)
} }
// Inverse of civil_from_days (Howard Hinnant's days_from_civil).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
// Parses the datetime shape masterdata uses ("2023/06/19 5:00:00", also tolerating
// '-' separators and a missing time part) into the same naive seconds-since-epoch
// scale format_datetime prints. Naive on purpose: every consumer compares a
// masterdata timestamp against a server timestamp, so both sides share the offset.
pub fn parse_datetime(text: &str) -> Option<u64> {
let text = text.trim();
if text.is_empty() {
return None;
}
let (date, time) = match text.split_once(' ') {
Some((date, time)) => (date, time),
None => (text, "0:0:0")
};
let mut date_parts = date.split(['/', '-']);
let y = date_parts.next()?.trim().parse::<i64>().ok()?;
let m = date_parts.next()?.trim().parse::<i64>().ok()?;
let d = date_parts.next()?.trim().parse::<i64>().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let mut time_parts = time.split(':');
let hh = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
let mm = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
let ss = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
let secs = days_from_civil(y, m, d) * 86400 + hh * 3600 + mm * 60 + ss;
if secs < 0 { None } else { Some(secs as u64) }
}
fn init_time(current_time: u64, server_data: &mut JsonValue, token: &str, max_time: u64, max: bool) { fn init_time(current_time: u64, server_data: &mut JsonValue, token: &str, max_time: u64, max: bool) {
let mut edited = false; let mut edited = false;
let default_time = 1709272800; let default_time = 1709272800;

View File

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

View File

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

View File

@@ -27,7 +27,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
async fn retire(Session { key, body }: Session) -> impl Responder { async fn retire(Session { key, body }: Session) -> impl Responder {
live_retire(&key, &body); live_retire(&key, &body);
if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 { if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 {
live_completed(body["master_live_id"].as_i64().unwrap(), body["level"].as_i32().unwrap(), true, 0, 0); // Body-derived, so defaulted rather than unwrapped: a retire is not worth a
// panicked worker either (live_completed ignores an unknown id/level).
live_completed(body["master_live_id"].as_i64().unwrap_or(0), body["level"].as_i32().unwrap_or(0), true, 0, 0);
} }
Api(Some(object!{ Api(Some(object!{
"stamina": {}, "stamina": {},
@@ -222,12 +224,15 @@ fn check_for_stale_data(server_data: &mut JsonValue, live_id: i64) {
let mut expired = array![]; let mut expired = array![];
let curr_time = global::timestamp(); let curr_time = global::timestamp();
for (i, live) in server_data["last_live_started"].members().enumerate() { for (i, live) in server_data["last_live_started"].members().enumerate() {
if live["expire_date_time"].as_u64().unwrap() < curr_time || live["master_live_id"] == live_id { // A record with no readable expiry is treated as expired rather than panicking:
// start_live always writes one, so this is a corrupt/hand-edited row.
let stale = live["expire_date_time"].as_u64().unwrap_or(0) < curr_time;
if stale || live["master_live_id"] == live_id {
expired.push(i).unwrap(); expired.push(i).unwrap();
} }
if live["expire_date_time"].as_u64().unwrap() < curr_time { if stale {
// User closed game after losing. Count this as a fail. // User closed game after losing. Count this as a fail.
live_completed(live["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), true, 0, 0); live_completed(live["master_live_id"].as_i64().unwrap_or(0), live["level"].as_i32().unwrap_or(0), true, 0, 0);
} }
} }
for i in expired.members() { for i in expired.members() {
@@ -243,34 +248,94 @@ fn get_end_live_deck_id(login_token: &str, body: &JsonValue) -> Option<i32> {
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?; let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?;
let rv = server_data["last_live_started"][index]["deck_slot"].as_i32()?; let rv = server_data["last_live_started"][index]["deck_slot"].as_i32()?;
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap()); check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0));
userdata::save_server_data(login_token, server_data); userdata::save_server_data(login_token, server_data);
Some(rv) Some(rv)
} }
pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option<u32> { // The whole payload the client POSTed to */start for this live, as recorded by
// start_live. /multi_live/end needs the boost and deck slot out of it, and its
// own request body carries neither.
pub fn get_started_live(login_token: &str, body: &JsonValue) -> Option<JsonValue> {
let server_data = userdata::get_server_data(login_token); let server_data = userdata::get_server_data(login_token);
if server_data["last_live_started"].is_null() { if server_data["last_live_started"].is_null() {
return None; return None;
} }
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?; let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?;
let rv = server_data["last_live_started"][index]["master_event_id"].as_u32()?; Some(server_data["last_live_started"][index].clone())
Some(rv)
} }
fn live_retire(login_token: &str, body: &JsonValue) { // Claims the record start_live left behind: it is returned AND removed in one atomic
// step, so of N ends arriving together for one live exactly one gets Some and every other
// gets None. /multi_live/end awards off that Some and answers a None from current state,
// which is what makes its duplicate protection deliberate rather than a side effect of
// get_end_live_deck_id's shape (that one only consumed when the record carried a numeric
// deck_slot, and only long after the awarding decision had been taken).
//
// The stale sweep check_for_stale_data does is folded in unchanged: records past their
// hour count as a fail and go, so live_end_ex's own later sweep finds nothing left to
// count twice. live_completed is called after the transaction commits - it writes to a
// different database and has no business running inside this one's write lock.
//
// Solo /live/end is deliberately NOT routed through here: it still consumes its record
// inside live_end_ex exactly as it always did.
pub fn take_started_live(login_token: &str, body: &JsonValue) -> Option<JsonValue> {
let live_id = body["master_live_id"].clone();
let curr_time = global::timestamp();
let mut failed: Vec<(i64, i32)> = Vec::new();
let taken = userdata::modify_server_data(login_token, |server_data| {
if server_data["last_live_started"].is_null() {
server_data["last_live_started"] = array![];
}
let mut taken: Option<JsonValue> = None;
let mut kept = array![];
for live in server_data["last_live_started"].members() {
let stale = live["expire_date_time"].as_u64().unwrap_or(0) < curr_time;
let mine = live["master_live_id"] == live_id;
if stale {
// User closed game after losing. Count this as a fail.
failed.push((
live["master_live_id"].as_i64().unwrap_or(0),
live["level"].as_i32().unwrap_or(0)
));
}
if mine && taken.is_none() {
taken = Some(live.clone());
} else if !stale && !mine {
kept.push(live.clone()).unwrap();
}
}
server_data["last_live_started"] = kept;
taken
});
for (id, level) in failed {
live_completed(id, level, true, 0, 0);
}
taken
}
pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option<u32> {
get_started_live(login_token, body)?["master_event_id"].as_u32()
}
pub fn live_retire(login_token: &str, body: &JsonValue) {
let mut server_data = userdata::get_server_data(login_token); let mut server_data = userdata::get_server_data(login_token);
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap()); // A body with no master_live_id matches nothing (0 is not a live id); the stale sweep
// still runs, which is all a retire with a malformed body can honestly do.
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0));
userdata::save_server_data(login_token, server_data); userdata::save_server_data(login_token, server_data);
} }
fn start_live(login_token: &str, body: &JsonValue) { pub fn start_live(login_token: &str, body: &JsonValue) {
let mut server_data = userdata::get_server_data(login_token); let mut server_data = userdata::get_server_data(login_token);
if server_data["last_live_started"].is_null() { if server_data["last_live_started"].is_null() {
server_data["last_live_started"] = array![]; server_data["last_live_started"] = array![];
} }
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap()); // Body-derived: /multi_live/start forwards a body this server never validated, and a
// start with no live id must record a useless record rather than panic a worker.
check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0));
let mut to_save = body.clone(); let mut to_save = body.clone();
// The user has 1 hour to complete a live // The user has 1 hour to complete a live
to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into(); to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into();
@@ -311,24 +376,32 @@ fn get_clear_count(id: i64, user: &JsonValue) -> i64 {
rv rv
} }
pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> JsonValue { // `record_high_score=false` plays this live for the clear count and the max combo but
// leaves the stored high score alone: it enters the keep-best comparison below as 0, so
// the existing record always wins and is what gets reported back. That is the official
// treatment of a public multi live ("本イベントでは HIGH SCOREは更新されません"), which
// /multi_live/end asks for — see the score-board note in live_end_ex.
pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool, record_high_score: bool) -> JsonValue {
if user["tutorial_step"].as_i32().unwrap() < 130 { if user["tutorial_step"].as_i32().unwrap() < 130 {
return JsonValue::Null; return JsonValue::Null;
} }
// Every field here comes off the request body, so a malformed end must not panic a
// worker: a missing id/level/score reads as 0, which is what an empty live record
// would have held anyway.
let mut rv = object!{ let mut rv = object!{
"master_live_id": data["master_live_id"].as_i64().unwrap(), "master_live_id": data["master_live_id"].as_i64().unwrap_or(0),
"level": data["level"].as_i64().unwrap(), "level": data["level"].as_i64().unwrap_or(0),
"clear_count": 1, "clear_count": 1,
"high_score": data["live_score"]["score"].as_i64().unwrap(), "high_score": if record_high_score { data["live_score"]["score"].as_i64().unwrap_or(0) } else { 0 },
"max_combo": data["live_score"]["max_combo"].as_i64().unwrap(), "max_combo": data["live_score"]["max_combo"].as_i64().unwrap_or(0),
"auto_enable": 1, //whats this? "auto_enable": 1, //whats this?
"updated_time": global::timestamp() "updated_time": global::timestamp()
}; };
let mut has = false; let mut has = false;
for current in user["live_list"].members_mut() { for current in user["live_list"].members_mut() {
if current["master_live_id"] == rv["master_live_id"] && (current["level"] == rv["level"] || data["level"].as_i32().unwrap() == 0) { if current["master_live_id"] == rv["master_live_id"] && (current["level"] == rv["level"] || data["level"].as_i32().unwrap_or(0) == 0) {
has = true; has = true;
if add { if add {
rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into(); rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into();
@@ -348,7 +421,7 @@ pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> Js
} }
current["updated_time"] = rv["updated_time"].clone(); current["updated_time"] = rv["updated_time"].clone();
rv["level"] = current["level"].clone(); rv["level"] = current["level"].clone();
if data["level"].as_i32().unwrap() != 0 { if data["level"].as_i32().unwrap_or(0) != 0 {
break; break;
} }
} }
@@ -586,22 +659,46 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis
} }
pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -> JsonValue { pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -> JsonValue {
live_end_ex(req, key, body, skipped, true, true)
}
// consume_lp=false is for lives whose stamina was already taken up front
// (/multi_live/start does that, because its response reports consumed_stamina).
// Everything else still scales off lp_used, so the caller injects use_lp.
//
// update_score_board=false plays the live without recording a score anywhere: neither the
// account's own high score for the song nor the per-song board /live/ranking serves. Only
// /multi_live/end passes false, and only for a PUBLIC (random matchmaking) room — see the
// privacy note there. Everything else about the live is untouched: the clear count, the
// max combo, the clear-rate counters, missions and every reward are computed off this
// play's own score exactly as they always were.
pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool, consume_lp: bool, update_score_board: bool) -> JsonValue {
let mut user2 = userdata::get_acc_home(&key); let mut user2 = userdata::get_acc_home(&key);
let mut user = userdata::get_acc(&key); let mut user = userdata::get_acc(&key);
let mut user_missions = userdata::get_acc_missions(&key); let mut user_missions = userdata::get_acc_missions(&key);
let mut chats = userdata::get_acc_chats(&key); let mut chats = userdata::get_acc_chats(&key);
// Read once, off the request, with a default each: a client (or a replayed/forged
// POST) that omits a field must get a wrong-but-harmless result, not a panicked
// worker. /multi_live/end is the reachable path — it forwards a body this handler
// never validated — but the solo path gets the same treatment, and for a well-formed
// body every one of these is exactly what the old unwrap produced.
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);
let max_combo = body["live_score"]["max_combo"].as_i64().unwrap_or(0);
let jp = items::get_region(req.headers()); let jp = items::get_region(req.headers());
let first_clear = !skipped let first_clear = !skipped
&& user["tutorial_step"].as_i32().unwrap() >= 130 && user["tutorial_step"].as_i32().unwrap() >= 130
&& get_clear_count(body["master_live_id"].as_i64().unwrap(), &user) == 0; && get_clear_count(live_id, &user) == 0;
let live = if skipped { let live = if skipped {
items::use_item(&object!{ items::use_item(&object!{
value: 21000001, value: 21000001,
amount: 1, amount: 1,
consumeType: 4 consumeType: 4
}, body["live_boost"].as_i64().unwrap(), &mut user); }, body["live_boost"].as_i64().unwrap_or(0), &mut user);
update_live_data(&mut user, &object!{ update_live_data(&mut user, &object!{
master_live_id: body["master_live_id"].clone(), master_live_id: body["master_live_id"].clone(),
level: 0, level: 0,
@@ -609,9 +706,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
score: 1, score: 1,
max_combo: 1 max_combo: 1
} }
}, false) }, false, update_score_board)
} else { } else {
update_live_data(&mut user, &body, true) update_live_data(&mut user, &body, true, update_score_board)
}; };
//1273009, 1273010, 1273011, 1273012 //1273009, 1273010, 1273011, 1273012
@@ -626,21 +723,27 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
} }
} }
// The account the per-song board is keyed by, or 0 to leave the board alone —
// clear_rate::update_live_score's own "no user, no board entry" guard, which is how
// /live/retire already counts a play it must not rank. The clear-rate counters still
// get the play either way: a public multi live really was played.
let board_uid = if update_score_board { user["user"]["id"].as_i64().unwrap() } else { 0 };
let missions; let missions;
if skipped { if skipped {
live_completed(body["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), false, live["high_score"].as_i64().unwrap(), user["user"]["id"].as_i64().unwrap()); live_completed(live_id, live["level"].as_i32().unwrap_or(0), false, live["high_score"].as_i64().unwrap_or(0), board_uid);
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user); let clear_count = get_clear_count(live_id, &user);
missions = get_live_mission_completed_ids(&user, body["master_live_id"].as_i64().unwrap(), live["high_score"].as_i64().unwrap(), live["max_combo"].as_i64().unwrap(), clear_count, live["level"].as_i64().unwrap(), false, false).unwrap_or(array![]); missions = get_live_mission_completed_ids(&user, live_id, live["high_score"].as_i64().unwrap_or(0), live["max_combo"].as_i64().unwrap_or(0), clear_count, live["level"].as_i64().unwrap_or(0), false, false).unwrap_or(array![]);
} else { } else {
live_completed(body["master_live_id"].as_i64().unwrap(), body["level"].as_i32().unwrap(), false, body["live_score"]["score"].as_i64().unwrap(), user["user"]["id"].as_i64().unwrap()); live_completed(live_id, level as i32, false, score, board_uid);
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user); let clear_count = get_clear_count(live_id, &user);
let is_full_combo = (body["live_score"]["good"].as_i32().unwrap_or(1) + body["live_score"]["bad"].as_i32().unwrap_or(1) + body["live_score"]["miss"].as_i32().unwrap_or(1)) == 0; let is_full_combo = (body["live_score"]["good"].as_i32().unwrap_or(1) + body["live_score"]["bad"].as_i32().unwrap_or(1) + body["live_score"]["miss"].as_i32().unwrap_or(1)) == 0;
let is_perfect = is_full_combo && body["live_score"]["great"].as_i32().unwrap_or(1) == 0; let is_perfect = is_full_combo && body["live_score"]["great"].as_i32().unwrap_or(1) == 0;
missions = get_live_mission_completed_ids(&user, body["master_live_id"].as_i64().unwrap(), body["live_score"]["score"].as_i64().unwrap(), body["live_score"]["max_combo"].as_i64().unwrap(), clear_count, body["level"].as_i64().unwrap(), is_full_combo, is_perfect).unwrap_or(array![]); missions = get_live_mission_completed_ids(&user, live_id, score, max_combo, clear_count, level, is_full_combo, is_perfect).unwrap_or(array![]);
if is_full_combo { if is_full_combo {
if items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() { if items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() {
@@ -665,13 +768,13 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
if is_perfect && items::advance_mission(1177001, 1, 1, &mut user_missions).is_some() { if is_perfect && items::advance_mission(1177001, 1, 1, &mut user_missions).is_some() {
cleared_missions.push(1177001).unwrap(); cleared_missions.push(1177001).unwrap();
} }
if is_perfect && body["level"].as_i32().unwrap() == 4 && items::advance_mission(1177002, 1, 1, &mut user_missions).is_some() { if is_perfect && level == 4 && items::advance_mission(1177002, 1, 1, &mut user_missions).is_some() {
cleared_missions.push(1177002).unwrap(); cleared_missions.push(1177002).unwrap();
} }
} }
update_live_mission_data(&mut user, &object!{ update_live_mission_data(&mut user, &object!{
master_live_id: body["master_live_id"].as_i64().unwrap(), master_live_id: live_id,
clear_master_live_mission_ids: missions.clone() clear_master_live_mission_ids: missions.clone()
}); });
@@ -679,7 +782,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
let mut reward_list = give_mission_rewards(&mut user, &mut user2, &missions, &mut user_missions, &mut cleared_missions, &mut chats, (lp_used / 10) as i64, jp); let mut reward_list = give_mission_rewards(&mut user, &mut user2, &missions, &mut user_missions, &mut cleared_missions, &mut chats, (lp_used / 10) as i64, jp);
items::lp_modification(&mut user, lp_used as u64, true); if consume_lp {
items::lp_modification(&mut user, lp_used as u64, true);
}
items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions); items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions);
@@ -761,7 +866,7 @@ mod tests {
master_live_id: live_id, master_live_id: live_id,
level: level, level: level,
live_score: { score: score, max_combo: combo } live_score: { score: score, max_combo: combo }
}, true); }, true, true);
userdata::save_acc(token, user); userdata::save_acc(token, user);
} }
@@ -769,6 +874,118 @@ mod tests {
get_clear_count(live_id, &userdata::get_acc(token)) get_clear_count(live_id, &userdata::get_acc(token))
} }
// The same clear with the score board switched off, which is what live_end_ex does for
// a multi live played in a PUBLIC room. Returns the `live` record the client is
// answered with.
fn record_unscored_clear(token: &str, live_id: i64, level: i64, score: i64, combo: i64) -> JsonValue {
let mut user = userdata::get_acc(token);
let live = update_live_data(&mut user, &object!{
master_live_id: live_id,
level: level,
live_score: { score: score, max_combo: combo }
}, true, false);
userdata::save_acc(token, user);
live
}
fn stored_live(token: &str, live_id: i64) -> JsonValue {
userdata::get_acc(token)["live_list"]
.members()
.find(|l| l["master_live_id"] == live_id)
.cloned()
.unwrap_or(JsonValue::Null)
}
// /multi_live/end's score-board branch, at the write it actually gates. live_end_ex
// itself cannot be driven from a test (items::get_region reaches the clap parser, which
// rejects the harness's own arguments), so the two halves of the gate are pinned where
// they live: the account's own high score here, and the per-song board next door in
// clear_rate.
//
// A PRIVATE room is the unchanged path: it records exactly like a solo live, keep-best.
#[test]
fn a_private_multi_live_records_its_score_keep_best() {
let _lock = crate::runtime::lock_test_data_path();
let token = "sb_private_room";
register_account(token);
record_clear(token, STOCK_LIVE_ID, 4, 500000, 320);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 500000);
// A better score wins...
record_clear(token, STOCK_LIVE_ID, 4, 600000, 340);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 600000);
// ...and a worse one never overwrites it.
record_clear(token, STOCK_LIVE_ID, 4, 100000, 20);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 600000);
assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 3);
}
// A PUBLIC room is the official behaviour: the play counts, the score does not.
#[test]
fn a_public_multi_live_leaves_the_high_score_alone() {
let _lock = crate::runtime::lock_test_data_path();
let token = "sb_public_room";
register_account(token);
record_clear(token, STOCK_LIVE_ID, 4, 500000, 320);
// A score that would beat the stored one is not recorded, and the answer the client
// gets back reports the record that still stands rather than this play's score.
let live = record_unscored_clear(token, STOCK_LIVE_ID, 4, 900000, 400);
assert_eq!(live["high_score"], 500000);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 500000);
// Everything else about the live still lands: the clear counts and the combo is a
// real one (the client only ever said HIGH SCORE was not updated).
assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 2);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["max_combo"], 400);
}
// The solo path is untouched by the multi branch: live_end — the only entry /live/end
// and /live/skip have — calls live_end_ex with update_score_board hardcoded true, so a
// solo live writes exactly what it always did.
#[test]
fn the_solo_live_path_still_records_its_score() {
let _lock = crate::runtime::lock_test_data_path();
let token = "sb_solo_path";
register_account(token);
record_clear(token, STOCK_LIVE_ID, 4, 450000, 300);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 450000);
// /live/skip's shape: level 0 matches whatever level is stored, its stand-in score
// of 1 can never beat the record, and the record is what it answers with.
let mut user = userdata::get_acc(token);
let live = update_live_data(&mut user, &object!{
master_live_id: STOCK_LIVE_ID,
level: 0,
live_score: { score: 1, max_combo: 1 }
}, false, true);
userdata::save_acc(token, user);
assert_eq!(live["high_score"], 450000);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 450000);
}
#[test]
fn a_public_multi_live_on_a_new_song_records_no_score_at_all() {
let _lock = crate::runtime::lock_test_data_path();
let token = "sb_public_first_play";
register_account(token);
// The first ever play of the song is a public multi: the song gets a live record
// so the clear counts, but there is no high score to show for it.
let live = record_unscored_clear(token, STOCK_LIVE_ID, 4, 900000, 400);
assert_eq!(live["high_score"], 0);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 0);
assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 1);
// A later solo play sets it for real.
record_clear(token, STOCK_LIVE_ID, 4, 300000, 100);
assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 300000);
}
#[test] #[test]
fn clear_count_persists_with_custom_songs_disabled() { fn clear_count_persists_with_custom_songs_disabled() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();

797
src/router/multi_live.rs Normal file
View File

@@ -0,0 +1,797 @@
// Wire format shared with the C# client rewrite; see docs/multi-live-ws-protocol.md.
mod proto;
// Lobby/room state machine, WebSocket-free so it can be tested without a socket.
mod rooms;
// The /multi_live/ws endpoint itself.
mod ws;
use jzon::{object, JsonValue};
use actix_web::{web, HttpRequest, Responder};
use crate::router::{databases, event, event_ranking, global, items, live, userdata, Session, Api};
// The relay's expiry timers (held slots, empty rooms, dead connections). Started once from
// run_server; see ws::start_sweeper for why it is not lazily started by the first upgrade.
pub use ws::start_sweeper;
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/multi_live")
.route("/start", web::post().to(start))
.route("/end", web::post().to(end))
// The Photon replacement. Note this sits inside the /api scope, so the
// handshake goes through webui_fallback and must carry the usual
// aoharu-asset-version header like every other game request.
.route("/ws", web::get().to(ws::ws))
);
}
// Shock.MULTI_LIVE_END_STATUS, the enum RecvMultiLiveEndRData.is_penalty_miss_ratio
// is cast to (MngLiveData.SendMultiLiveEnd).
// NONE(0) - normal result, client processes every reward list.
// MISS_RATIO_PENALTY_STATUS(1) - MngLiveData bails out right after user/stamina,
// LiveScene.OnMultiLiveEnd leaves the room and
// returns to the multi-event top. Nothing is awarded.
// GREAT_PERFECT_LOW_RATIO_STATUS(2) - rewards still apply, the client only raises
// MultiEventLiveResult.IsOpenCautionDialog.
const STATUS_NONE: u8 = 0;
const STATUS_MISS_RATIO_PENALTY: u8 = 1;
const STATUS_GREAT_PERFECT_LOW_RATIO: u8 = 2;
// Shock.COMMON_CONST.RATIO_DIVISOR. Every ratio in masterdata (event_score._eventPointRatio
// / ._eventBoostRatio, live_boost._eventPointRatio, multievent_rankbonus._eventPtBonus,
// multievent_card_bonus._pointBonusRatioList) is stored in 1/10000 and divided by this.
const RATIO_ONE: i64 = 10000;
// Extended-protocol revision this feature requires (X-Protocol-Version, the same ladder
// card.rs=2 / custom_card.rs=3 use). Older client builds carry incompatible multi
// implementations — pre-relay wire framing and pre-rework flows — so every multi_live
// surface (start, end, and the WS upgrade in ws.rs) refuses anything below it.
// 4 = multi-live over the self-hosted WS relay (permanent co-op).
pub const PROTOCOL_VERSION: u32 = 4;
fn protocol_too_old(req: &HttpRequest) -> bool {
global::client_protocol_version(req) < PROTOCOL_VERSION
}
fn const_value(id: &str, default: i64) -> i64 {
let raw = &databases::CONST[id]["value"];
raw.as_str()
.and_then(|v| v.parse::<i64>().ok())
.or_else(|| raw.as_i64())
.unwrap_or(default)
}
fn boost_lp(live_boost: i64) -> i64 {
databases::LIVE_BOOST[live_boost.to_string()]["lp"]
.as_i64()
.unwrap_or(10 * live_boost)
}
fn boost_event_point_ratio(live_boost: i64) -> i64 {
databases::LIVE_BOOST[live_boost.to_string()]["eventPointRatio"]
.as_i64()
.unwrap_or(RATIO_ONE * live_boost.max(1))
}
// MusicLevelMst._fullCombo — the note count both client ratio checks divide by.
fn note_count(master_live_id: i64, level: i64) -> Option<i64> {
let music_id = databases::LIVE_LIST[master_live_id.to_string()]["masterMusicId"].as_i64()?;
let row = &databases::MUSIC_LEVEL[format!("{}_{}", music_id, level)];
if row.is_empty() {
return None;
}
let notes = row["fullCombo"].as_i64()?;
if notes <= 0 { None } else { Some(notes) }
}
// Aoharu.MultiUtil.IsPenalty / IsHalved, evaluated server-side because
// is_penalty_miss_ratio is what the client actually obeys (both MultiUtil helpers
// are unreferenced in the client — they were only ever a local preview).
//
// IsPenalty: (MULTI_PENALTY_MISS_RATIO / 100) * fullCombo <= miss
// IsHalved: perfect + great < (MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO / 100) * fullCombo
// (skipped for LIVE_LEVEL 1, per `if ((int)level == 1) return false;`)
//
// MultiUtil.IsHalved reads Perfect + Good, but only because the client-side
// MultiLiveResultProtocolData(userId, LiveScore, bool) ctor never assigns Great
// (IL2CPP @4769631) — it has no great count to use. The const is named
// ..._GREAT_PERFECT_NOTES_MIN_RATIO and the server does receive the full LiveScore,
// so perfect + great is used here.
//
// MULTI_PENALTY_NO_PLAY_MISS_RATIO (5000) is unreachable as a real miss ratio and is
// the value the ratio takes when nothing was judged at all: a client that never played
// reports 0 misses, which the 70% rule would wave through.
//
// Both checks mirror MultiUtil's "missing MusicLevelMst row => false" shape.
fn multi_live_end_status(body: &JsonValue) -> u8 {
let score = &body["live_score"];
let notes = match note_count(
body["master_live_id"].as_i64().unwrap_or(0),
body["level"].as_i64().unwrap_or(0)
) {
Some(n) => n,
None => return STATUS_NONE
};
let perfect = score["perfect"].as_i64().unwrap_or(0);
let great = score["great"].as_i64().unwrap_or(0);
let good = score["good"].as_i64().unwrap_or(0);
let bad = score["bad"].as_i64().unwrap_or(0);
let miss = score["miss"].as_i64().unwrap_or(0);
let penalty_ratio = const_value("MULTI_PENALTY_MISS_RATIO", 70);
let no_play_ratio = const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", 5000);
let judged = perfect + great + good + bad + miss;
let penalised = if judged == 0 {
no_play_ratio >= penalty_ratio
} else {
miss * 100 >= penalty_ratio * notes
};
if penalised {
return STATUS_MISS_RATIO_PENALTY;
}
if body["level"].as_i64().unwrap_or(0) != 1 {
let min_ratio = const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", 50);
if (perfect + great) * 100 < min_ratio * notes {
return STATUS_GREAT_PERFECT_LOW_RATIO;
}
}
STATUS_NONE
}
// The event-point yield of one multi live, from event_score.csv (Shock.EventScoreMst).
//
// Neither ew nor the reconstructed client had an existing consumer to copy: EventData
// exposes _eventLivePointBase / _eventPointRatio / _eventBoostRatio as EventLiveBasePoint
// / EventPointRatio / EventBoostRatio (EventData.cs:134-156) but nothing in the client
// ever reads those three properties — the award has always been computed server-side.
// The interpretation used here is the one the field names and the client's own ratio
// convention dictate, and the one this port is specified against:
//
// points = _eventLivePointBase
// * _eventPointRatio / RATIO_DIVISOR (per-event scaling)
// * _eventBoostRatio / RATIO_DIVISOR (per-event boost worth)
// * live_boost._eventPointRatio / RATIO_DIVISOR (the boost actually spent)
//
// The last factor mirrors LiveBoostMst.GetEventPointRatio (LiveBoostMst.cs:131-137),
// which is `_eventPointRatio / RATIO_DIVISOR` — the boost level itself for the stock
// table (boost 3 -> 30000/10000 -> 3).
//
// All four multi events (108/111/115/119) carry base 10, pointRatio 10000, boostRatio
// 35000, so one boost-1 multi live is worth 35 points.
//
// Division is deferred to the end so the x3.5 boost ratio does not truncate to x3 the
// way the client's integer GetEventPointRatio would.
fn event_live_points(event_id: u32, live_boost: i64) -> i64 {
let row = &databases::EVENT_SCORE[event_id.to_string()];
if row.is_empty() {
// Should be unreachable: event_score.csv has a row for every event, including
// all four multi events. An event with no row is worth nothing rather than
// silently falling back to an invented constant.
println!("multi_live: no event_score row for event {event_id}, awarding 0 event points");
return 0;
}
let base = row["eventLivePointBase"].as_i64().unwrap_or(0);
let point_ratio = row["eventPointRatio"].as_i64().unwrap_or(0);
let boost_ratio = row["eventBoostRatio"].as_i64().unwrap_or(0);
base * point_ratio * boost_ratio * boost_event_point_ratio(live_boost)
/ (RATIO_ONE * RATIO_ONE * RATIO_ONE)
}
// The stamina this live actually cost, as recorded by /multi_live/start. This is what
// live_end_ex scales every reward off, and it is deliberately read without reference to
// the backing event: a closed event suppresses scoring, never rewards.
fn recorded_lp(started: Option<&JsonValue>) -> i64 {
let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0);
started
.and_then(|s| s["use_lp"].as_i64())
.unwrap_or_else(|| boost_lp(live_boost))
.max(0)
}
// The event a multi live should actually score against, or None when it should score
// against nothing.
//
// Multi is a permanent feature here, entered from a client-side button rather than from
// a live event, so the client faithfully sends the backing event id (108) even though
// that event is closed and stays closed by choice. Awarding against a closed event would
// write event points and ranking rows for a season that is not running, so the whole
// event-point path stays dormant — and lights up on its own, with no further change
// here, if an event is ever actually opened.
//
// Nothing else about the live is affected: rewards, EXP, bond, missions and the response
// shape all come out of live_end_ex exactly as they do for an in-session event.
fn scoring_event(started: Option<&JsonValue>, now: u64) -> Option<u32> {
started
.and_then(|s| s["master_event_id"].as_u32())
.filter(|id| *id != 0)
.filter(|id| event::is_in_session(*id, now))
}
// The whole award for one multi live: the event_score yield, the finishing-position
// bonus from multievent_rankbonus, and the GREAT_PERFECT_LOW_RATIO halving.
fn multi_event_points(event_id: u32, live_boost: i64, players: i64, live_rank: i64, status: u8) -> i64 {
let mut points = event_live_points(event_id, live_boost);
points = points * (RATIO_ONE + rank_bonus(players, live_rank)) / RATIO_ONE;
if status == STATUS_GREAT_PERFECT_LOW_RATIO {
// MultiUtil calls this state "halved" — the caution dialog goes with a
// reduced yield, not a forfeited one.
points /= 2;
}
points
}
// multievent_rankbonus._eventPtBonus for this party size / finishing position.
// The table only covers 2-4 players with liveRank <= playerCount; anything outside
// it (a solo room, a rank the table has no row for) simply earns no bonus.
fn rank_bonus(player_count: i64, live_rank: i64) -> i64 {
databases::MULTIEVENT_RANK_BONUS[format!("{}_{}", player_count, live_rank)]["eventPtBonus"]
.as_i64()
.unwrap_or(0)
}
// LiveScene.MultiTask4 builds other_live_score_list from MultiPlayManager.AllPlayers,
// so the poster is already one of its entries. Fall back to len + 1 if a caller ever
// sends a list that genuinely excludes itself.
fn player_count(body: &JsonValue, user_id: i64) -> i64 {
let list = &body["other_live_score_list"];
let contains_self = list
.members()
.any(|s| s["user_id"].as_i64() == Some(user_id));
let len = list.len() as i64;
if contains_self { len } else { len + 1 }
}
// Every field of Shock.RecvMultiLiveEndRData, so the penalty path still deserialises
// cleanly. The client reads user/stamina and then returns on status 1, but Notify()
// runs over the whole payload first.
fn barren_response(user: &JsonValue, status: u8) -> JsonValue {
object!{
"is_penalty_miss_ratio": status,
"gem": user["gem"].clone(),
"clear_master_live_mission_ids": [],
"user": user["user"].clone(),
"stamina": user["stamina"].clone(),
"character_list": [],
"card_list": [],
"card_sub_list": [],
"item_list": [],
"point_list": [],
"group_list": [],
"reward_list": [],
"gift_list": [],
"clear_mission_ids": [],
"event_point_list": user["event_point_list"].clone(),
"event_point_reward_list": [],
"ranking_change": [],
"music_mission_reward_list": [],
"event_ranking_data": {
"event_point_rank": 0,
"next_reward_rank_point": 0,
"event_score_rank": 0,
"next_reward_rank_score": 0
}
}
}
// Unlike /live/start, a multi live pays its stamina up front: the response reports
// consumed_stamina and the client never sends a boost with /multi_live/end. The
// amount actually taken is stashed on the recorded start payload so /multi_live/end
// can scale rewards off it without charging for it twice.
async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Responder {
// Older clients speak an incompatible multi — refuse before touching any state.
if protocol_too_old(&req) {
return Api(None);
}
// `token` is the room-party correlation key ("{userId}.{guid}") that all up to four
// 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
// — event must start a live exactly like an open one. Stamina comes off live_boost
// alone, so nothing on this path depends on the event being in session.
let live_boost = body["live_boost"].as_i64().unwrap_or(0);
let lp = boost_lp(live_boost).max(0);
let mut user = userdata::get_acc(&key);
// Settle regen first so the balance clamped against below is current.
items::lp_modification(&mut user, 0, true);
let available = user["stamina"]["stamina"].as_u64().unwrap_or(0);
// Clamped, not refused, and that IS the intended answer.
//
// The client gates on stamina at the entry to matching, never at the POST:
// MultiSelectionView.RoomCreation / OpenRoomSearchDialog and MultiRestart.RestartLive
// all run StaminaUtils.UseStaminaValue and divert to StaminaChargeScene when it says
// the balance is short, while MultiEventMatchingScene.ChangeScene — which is what
// actually calls SendMultiLiveStart, and does so off the host's _MoveScene RPC —
// computes the cost and drops the "insufficient" flag on the floor. Stamina only ever
// goes up between the gate and the POST (regen; the boost selector lives on the gated
// panels), so an honest client cannot arrive here short, and the official server was
// never asked what to do about it.
//
// So this is the unreachable-in-practice branch, and taking what is there is the
// benign resolution: the live still starts (a party of four must not be broken up by
// one member's balance), consumed_stamina reports what was really taken, and because
// every reward scales off that same recorded use_lp — see recorded_lp and live_end_ex —
// a short live pays out in proportion. Refusing would have to fail a live the other
// three players are already committed to; charging the full cost would mean inventing
// negative stamina.
let consumed = (lp as u64).min(available);
items::lp_modification(&mut user, consumed, true);
userdata::save_acc(&key, user);
body["use_lp"] = consumed.into();
live::start_live(&key, &body);
Api(Some(object!{
"consumed_stamina": consumed
}))
}
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).
if protocol_too_old(&req) {
return Api(None);
}
// Loaded once and reused by every path that answers without playing the live; the
// live itself re-reads it, because live_end_ex saves the account.
let account = userdata::get_acc(&key);
// The user's own clock, so a time-travelling account sees the same event window the
// rest of the game shows it.
let uid = account["user"]["id"].as_i64().unwrap_or(0);
// A body with no live id is answered like a duplicate: it cannot name a start record
// (matching one on a null id would let it claim a record started with the same
// malformed body) and nothing downstream can score it.
if body["master_live_id"].as_i64().is_none() {
println!("multi_live/end: uid {} posted no master_live_id — nothing to end", uid);
return Api(Some(barren_response(&account, STATUS_NONE)));
}
// The started-live record is what makes an end legitimate, and it is CLAIMED here:
// take_started_live returns it and removes it in one transaction, before anything is
// awarded, so of N ends arriving together exactly one can proceed. The consumption
// used to be a side effect of get_end_live_deck_id deep inside live_end_ex — which
// only fired when the record carried a numeric deck_slot, and fired long after the
// award had been decided, so a re-POST could be paid twice over.
//
// Failing the take means this live was already ended: two clients signed in to the
// SAME account both POST /multi_live/end for the one shared record, or the client
// retried a request whose response it never saw. Granting again would double-count the
// clear, the clear-rate counter, the play-count mission and the flat 17001001 drop.
// So the duplicate is answered from current state: a well-formed result the client can
// display, awarding nothing.
let started = live::take_started_live(&key, &body);
let started = started.as_ref();
if started.is_none() {
println!("multi_live/end: uid {} has no start record to spend — answering barren", uid);
return Api(Some(barren_response(&account, STATUS_NONE)));
}
let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0);
let lp_used = recorded_lp(started);
let event_id = scoring_event(started, global::set_time(global::timestamp(), uid, false));
let status = multi_live_end_status(&body);
println!(
"multi_live/end: uid {} score {} miss-ratio status {} ({})",
uid,
body["live_score"]["score"].as_i64().unwrap_or(-1),
status,
match status {
STATUS_MISS_RATIO_PENALTY => "PENALTY — results voided",
2 => "great/perfect low — points halved",
_ => "ok",
}
);
if status == STATUS_MISS_RATIO_PENALTY {
// The client discards the result and leaves the room, so nothing is granted. The
// record is already gone (claimed above), so the live is not left hanging until it
// expires either — and a re-POST of the same penalised result lands on the
// no-record branch rather than back here.
return Api(Some(barren_response(&account, status)));
}
// /multi_live/end carries neither live_boost nor deck_slot; use_lp is what
// live_end scales exp / gold / bond / mission rewards off.
let mut end_body = body.clone();
end_body["use_lp"] = lp_used.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 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.
rv["card_list"] = jzon::array![];
rv["card_sub_list"] = jzon::array![];
rv["group_list"] = jzon::array![];
rv["music_mission_reward_list"] = jzon::array![];
// MngLiveData.SendMultiLiveEnd dereferences event_ranking_data unconditionally,
// so it must be an object even when this live belongs to no event.
rv["event_ranking_data"] = object!{
"event_point_rank": 0,
"next_reward_rank_point": 0,
"event_score_rank": 0,
"next_reward_rank_score": 0
};
if let Some(event_id) = event_id {
// live_end already saved the account; re-read it before touching event points.
let mut user = userdata::get_acc(&key);
let user_id = user["user"]["id"].as_i64().unwrap_or(0);
let players = player_count(&body, user_id);
let live_rank = body["multi_live_rank"].as_i64().unwrap_or(0);
let points = multi_event_points(event_id, live_boost, players, live_rank, status);
event::give_event_points(event_id, points, &mut user);
userdata::save_acc(&key, user.clone());
let total = event::get_points(event_id, &user);
event_ranking::live_completed(event_id, user_id, total, 0);
let rank = event::get_rank(event_id, user_id as u64);
let mut event = event::get_event_data(&key, event_id);
event["point_ranking"]["point"] = total.into();
event["point_ranking"]["rank"] = rank.into();
event::save_event_data(&key, event_id, event);
rv["event_point_list"] = user["event_point_list"].clone();
rv["event_ranking_data"] = object!{
"event_point_rank": rank,
"next_reward_rank_point": 0,
"event_score_rank": rank,
"next_reward_rank_score": 0
};
}
Api(Some(rv))
}
#[cfg(test)]
mod tests {
use super::*;
use super::proto::{ClientMsg, Map, Value};
use jzon::array;
use std::time::Instant;
const STOCK_LIVE_ID: i64 = 1100101;
#[test]
fn multi_consts_resolve_from_masterdata() {
assert_eq!(const_value("MULTI_PENALTY_MISS_RATIO", -1), 70);
assert_eq!(const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", -1), 5000);
assert_eq!(const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", -1), 50);
assert_eq!(const_value("NOT_A_CONST", -1), -1);
}
#[test]
fn boost_costs_come_from_live_boost() {
assert_eq!(boost_lp(1), 10);
assert_eq!(boost_lp(10), 100);
assert_eq!(boost_event_point_ratio(1), RATIO_ONE);
assert_eq!(boost_event_point_ratio(3), 3 * RATIO_ONE);
}
// The four multi events from multievent_setting.csv, all sharing one event_score row
// shape: _eventLivePointBase 10, _eventPointRatio 10000, _eventBoostRatio 35000.
const MULTI_EVENTS: [u32; 4] = [108, 111, 115, 119];
#[test]
fn event_score_rows_back_every_multi_event() {
for event_id in MULTI_EVENTS {
let row = &databases::EVENT_SCORE[event_id.to_string()];
assert!(!row.is_empty(), "event_score row missing for event {event_id}");
assert_eq!(row["eventLivePointBase"].as_i64(), Some(10));
assert_eq!(row["eventPointRatio"].as_i64(), Some(RATIO_ONE));
assert_eq!(row["eventBoostRatio"].as_i64(), Some(35000));
}
}
#[test]
fn event_live_points_derive_from_event_score() {
// 10 * (10000/10000) * (35000/10000) * boost — and the x3.5 must not truncate
// to x3 on the way through.
assert_eq!(event_live_points(108, 1), 35);
assert_eq!(event_live_points(108, 2), 70);
assert_eq!(event_live_points(108, 10), 350);
for event_id in MULTI_EVENTS {
assert_eq!(event_live_points(event_id, 1), 35);
}
}
#[test]
fn event_live_points_fall_back_to_zero_without_a_row() {
assert!(databases::EVENT_SCORE["99999"].is_empty());
assert_eq!(event_live_points(99999, 1), 0);
assert_eq!(multi_event_points(99999, 1, 4, 1, STATUS_NONE), 0);
}
#[test]
fn multi_event_points_layer_rank_bonus_and_halving() {
// 1st of 4 is +30% (multievent_rankbonus 4/1 = 3000).
assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_NONE), 45);
// Last place earns the flat yield.
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35);
// The caution state halves whatever the bonus produced.
assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_GREAT_PERFECT_LOW_RATIO), 22);
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_GREAT_PERFECT_LOW_RATIO), 17);
}
// release_label 223061504, event 108's window: 2023/06/19 05:00:00 - 2023/06/28 04:59:59.
fn during_multi_event_108() -> u64 {
global::parse_datetime("2023/06/20 12:00:00").unwrap()
}
#[test]
fn masterdata_datetimes_round_trip() {
let t = global::parse_datetime("2023/06/19 5:00:00").unwrap();
assert_eq!(global::format_datetime(t), "2023-06-19 05:00:00");
// A bare date is midnight, and the blank cells the evergreen label uses parse
// to nothing rather than to the epoch.
assert_eq!(
global::parse_datetime("2023/06/19"),
global::parse_datetime("2023/06/19 0:00:00")
);
assert_eq!(global::parse_datetime(""), None);
assert_eq!(global::parse_datetime("null"), None);
}
#[test]
fn the_multi_backing_event_is_closed_by_design() {
// The evergreen label has no window at all and is always open.
assert!(event::release_label_is_open(1, during_multi_event_108()));
// Event 108 is in session only inside its own long-past label window...
assert!(event::is_in_session(108, during_multi_event_108()));
// ...and is closed now, which is the permanent-feature state multi runs in.
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
// whose clap parser chokes on the test-harness filter argument.
let now = global::timestamp();
assert!(!event::is_in_session(108, now), "event 108 must stay closed");
for event_id in MULTI_EVENTS {
assert!(!event::is_in_session(event_id, now));
}
}
#[test]
fn a_closed_backing_event_scores_against_nothing() {
let started = object!{ master_event_id: 108, live_boost: 1 };
// Closed today: the whole event-point path is skipped.
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
// whose clap parser chokes on the test-harness filter argument.
let now = global::timestamp();
assert_eq!(scoring_event(Some(&started), now), None);
// But the gate is a window test, not a hardcoded off switch — open the event
// and scoring resumes on its own.
assert_eq!(scoring_event(Some(&started), during_multi_event_108()), Some(108));
// A live with no backing event at all, and a missing start record, score nothing.
assert_eq!(scoring_event(Some(&object!{ master_event_id: 0 }), during_multi_event_108()), None);
assert_eq!(scoring_event(None, during_multi_event_108()), None);
}
// The end-to-end path cannot be exercised here: live_end_ex calls items::get_region,
// which reaches crate::get_args(), whose clap parser rejects the test harness's own
// filter argument. (live.rs's tests avoid live_end for the same reason.) What is
// testable, and what actually matters, is that the reward input is derived with no
// reference to the event window — so a closed event can only ever suppress scoring.
#[test]
fn a_closed_event_still_pays_its_normal_rewards() {
let started = object!{ master_event_id: 108, live_boost: 1, use_lp: 10, deck_slot: 2 };
let open = during_multi_event_108();
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
// whose clap parser chokes on the test-harness filter argument.
let closed = global::timestamp();
// The event gate is the only thing that moves between the two.
assert_eq!(scoring_event(Some(&started), open), Some(108));
assert_eq!(scoring_event(Some(&started), closed), None);
// The reward input is identical either way, and identical to what an eventless
// live would get for the same boost.
assert_eq!(recorded_lp(Some(&started)), 10);
assert_eq!(recorded_lp(Some(&object!{ live_boost: 1, use_lp: 10 })), 10);
// A start record with no recorded charge still falls back to the boost's cost.
assert_eq!(recorded_lp(Some(&object!{ master_event_id: 108, live_boost: 3 })), 30);
assert_eq!(recorded_lp(None), 0);
// And a suppressed event awards nothing, where an open one would pay 35.
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35);
}
// --- duplicate protection (the claim that gates the award) ----------------------
//
// /multi_live/end awards if and only if take_started_live hands it the record, and the
// record can be handed out exactly once. These drive that claim directly: the handler
// itself cannot be called from a test (live_end_ex reaches items::get_region, which
// reaches crate::get_args(), whose clap parser rejects the harness's own arguments).
// Two clients signed in to one account share a single started-live record, so the
// second /multi_live/end arrives after the first consumed it. The handler answers that
// from current state and grants nothing; this pins the state machine it keys off.
#[test]
fn a_second_end_for_one_session_finds_no_record_to_spend() {
let _lock = crate::runtime::lock_test_data_path();
let token = "multi_duplicate_end";
let start_body = object!{
master_live_id: STOCK_LIVE_ID,
level: 4,
deck_slot: 1,
live_boost: 1,
master_event_id: 108,
use_lp: 10
};
live::start_live(token, &start_body);
// First end: the record is there and pays for the live, and claiming it is what
// the handler does BEFORE it awards anything.
let started = live::take_started_live(token, &start_body);
assert!(started.is_some());
assert_eq!(recorded_lp(started.as_ref()), 10);
// Second end: nothing left to spend, which is the duplicate signal the handler
// short-circuits on, and the reward scale is zero even if it did not.
let again = live::take_started_live(token, &start_body);
assert!(again.is_none(), "the record must not survive the first end");
assert_eq!(recorded_lp(again.as_ref()), 0);
assert_eq!(scoring_event(again.as_ref(), during_multi_event_108()), None);
// And it stays gone however many times the client re-POSTs.
assert!(live::take_started_live(token, &start_body).is_none());
assert!(live::get_started_live(token, &start_body).is_none());
}
// The consumption used to hide inside get_end_live_deck_id, behind
// `record["deck_slot"].as_i32()?` — a start whose body carried no numeric deck_slot
// left the record in place, so every re-POST awarded again. Claiming is now about the
// record's existence and nothing else.
#[test]
fn a_start_with_no_deck_slot_is_still_consumed_by_the_first_end() {
let _lock = crate::runtime::lock_test_data_path();
let token = "multi_no_deck_slot";
for start_body in [
// No deck_slot at all...
object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10 },
// ...and one that is present but not a number.
object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10, deck_slot: "1" }
] {
live::start_live(token, &start_body);
let first = live::take_started_live(token, &start_body);
assert!(first.is_some(), "the record must be claimable without a deck_slot");
assert_eq!(recorded_lp(first.as_ref()), 10);
let second = live::take_started_live(token, &start_body);
assert!(second.is_none(), "a re-POST must find nothing left to award off");
}
}
// Two ends landing together — the normal case for one account signed in twice, and
// the one the old shape got wrong: both read the record, both passed the guard, both
// awarded. The claim is a single transaction, so exactly one of any number of racing
// ends can proceed.
#[test]
fn concurrent_ends_claim_the_record_exactly_once() {
let _lock = crate::runtime::lock_test_data_path();
let token = "multi_concurrent_end";
let start_body = object!{
master_live_id: STOCK_LIVE_ID,
level: 4,
deck_slot: 1,
live_boost: 1,
master_event_id: 108,
use_lp: 10
};
// Create the account before the threads start: the claim itself is atomic, the
// lazy account creation behind it is not, and that is not what is under test.
live::start_live(token, &start_body);
let claims = std::sync::atomic::AtomicUsize::new(0);
std::thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
if live::take_started_live(token, &start_body).is_some() {
claims.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
});
}
});
assert_eq!(
claims.load(std::sync::atomic::Ordering::SeqCst),
1,
"exactly one of eight simultaneous ends may award"
);
assert!(live::get_started_live(token, &start_body).is_none());
}
#[test]
fn rank_bonus_matches_multievent_rankbonus() {
assert_eq!(rank_bonus(4, 1), 3000);
assert_eq!(rank_bonus(4, 4), 0);
assert_eq!(rank_bonus(2, 1), 1000);
// Rows the table does not cover earn nothing rather than panicking.
assert_eq!(rank_bonus(1, 1), 0);
assert_eq!(rank_bonus(4, 9), 0);
}
fn score(perfect: i64, great: i64, good: i64, bad: i64, miss: i64) -> JsonValue {
object!{
master_live_id: STOCK_LIVE_ID,
level: 4,
live_score: {
perfect: perfect, great: great, good: good, bad: bad, miss: miss
}
}
}
#[test]
fn end_status_follows_multiutil_ratios() {
let notes = note_count(STOCK_LIVE_ID, 4).expect("music_level row");
// A clean full combo is normal.
assert_eq!(multi_live_end_status(&score(notes, 0, 0, 0, 0)), STATUS_NONE);
// 70% or more of the notes missed trips the penalty.
assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, notes)), STATUS_MISS_RATIO_PENALTY);
// Nothing judged at all is the "no play" case the 70% rule would wave through.
assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, 0)), STATUS_MISS_RATIO_PENALTY);
// Under half the notes as perfect/great, but few enough misses to avoid the
// penalty, is the caution ("halved") state.
let goods = notes - (notes / 4) - 1;
assert_eq!(
multi_live_end_status(&score(notes / 4, 0, goods, 0, 1)),
STATUS_GREAT_PERFECT_LOW_RATIO
);
// LIVE_LEVEL 1 is exempt from the great/perfect check.
let mut beginner = score(0, 0, 0, 0, 0);
beginner["level"] = 1.into();
beginner["live_score"]["good"] = note_count(STOCK_LIVE_ID, 1).unwrap().into();
assert_eq!(multi_live_end_status(&beginner), STATUS_NONE);
// A live with no MusicLevelMst row never penalises, like MultiUtil.
let mut unknown = score(0, 0, 0, 0, 0);
unknown["master_live_id"] = 999999999i64.into();
assert_eq!(multi_live_end_status(&unknown), STATUS_NONE);
}
#[test]
fn player_count_does_not_double_count_the_poster() {
let body = object!{
other_live_score_list: array![
object!{ user_id: 7 },
object!{ user_id: 8 },
object!{ user_id: 9 }
]
};
// LiveScene includes the poster in the list.
assert_eq!(player_count(&body, 8), 3);
// A list that omits the poster still yields the real party size.
assert_eq!(player_count(&body, 42), 4);
}
}

View File

@@ -0,0 +1,970 @@
// Wire format for the multi-live WebSocket relay, per docs/multi-live-ws-protocol.md.
//
// The C# client rewrite encodes/decodes the exact same bytes, so this module is a
// straight transcription of the spec tables rather than idiomatic Rust: every field is
// written in declaration order, all integers are little-endian, strings are UTF-8 behind
// a u16 length, and counts are u8. Nothing here is serde-driven - the type tags mirror
// Photon's Hashtable boxing (an Int32 must arrive as Int32 or GetCurrentRoomEventId's
// exact `is int` check fails), and a derive would hide that.
//
// Both directions are implemented in both halves (encode + decode for client messages
// AND for server messages) even though the relay only ever decodes the former and
// encodes the latter. The unused halves are what the round-trip tests exercise, and they
// double as the reference the C# side is written against.
use std::fmt;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
// Value tags (see "Value encoding" in the spec).
pub const TAG_NULL: u8 = 0;
pub const TAG_INT: u8 = 1;
pub const TAG_STRING: u8 = 2;
pub const TAG_BOOL: u8 = 3;
pub const TAG_DOUBLE: u8 = 4;
// Client -> server opcodes.
pub const OP_AUTH: u8 = 1;
pub const OP_JOIN_LOBBY: u8 = 2;
pub const OP_LEAVE_LOBBY: u8 = 3;
pub const OP_CREATE_ROOM: u8 = 4;
pub const OP_JOIN_ROOM: u8 = 5;
pub const OP_JOIN_RANDOM: u8 = 6;
pub const OP_LEAVE_ROOM: u8 = 7;
pub const OP_REJOIN: u8 = 8;
pub const OP_SET_PLAYER_PROPS: u8 = 9;
pub const OP_SET_ROOM_PROPS: u8 = 10;
pub const OP_RPC: u8 = 11;
pub const OP_PING: u8 = 12;
// Server -> client opcodes.
pub const OP_AUTH_OK: u8 = 20;
pub const OP_JOINED_LOBBY: u8 = 21;
pub const OP_LEFT_LOBBY: u8 = 22;
pub const OP_JOINED_ROOM: u8 = 23;
pub const OP_CREATE_FAILED: u8 = 24;
pub const OP_JOIN_FAILED: u8 = 25;
pub const OP_PLAYER_ENTERED: u8 = 26;
pub const OP_PLAYER_LEFT: u8 = 27;
pub const OP_LEFT_ROOM: u8 = 28;
pub const OP_ROOM_PROPS_CHANGED: u8 = 29;
pub const OP_PLAYER_PROPS_CHANGED: u8 = 30;
pub const OP_MASTER_SWITCHED: u8 = 31;
// Distinct name from the client-side OP_RPC (11); same message, opposite direction.
pub const OP_RPC_BROADCAST: u8 = 32;
pub const OP_PONG: u8 = 33;
pub const OP_KICKED: u8 = 34;
// Close codes. The spec names 4001 and 4002; 4000 covers the framing errors it
// describes ("Text frames are a protocol error -> close", malformed frame, unknown
// opcode) without naming a code for them.
pub const CLOSE_PROTOCOL: u16 = 4000;
pub const CLOSE_UNAUTHENTICATED: u16 = 4001;
pub const CLOSE_RATE_LIMIT: u16 = 4002;
// Added with the liveness sweep: nothing was heard from this connection for three ping
// intervals, so the relay stops holding its seat. See LIVENESS_TIMEOUT in ws.rs.
pub const CLOSE_IDLE_TIMEOUT: u16 = 4003;
// Photon ErrorCode mirrors, so the client's existing logging keeps its meaning.
pub const ERR_GAME_ID_ALREADY_EXISTS: i32 = 32766;
pub const ERR_GAME_FULL: i32 = 32765;
pub const ERR_GAME_CLOSED: i32 = 32764;
pub const ERR_NO_RANDOM_MATCH_FOUND: i32 = 32760;
pub const ERR_GAME_DOES_NOT_EXIST: i32 = 32758;
// Kicked causes. A room only dies once nobody is left to be told, so cause 1 is still
// reserved — encoded, decoded and tested so the C# side can be written against it now.
#[allow(dead_code)]
pub const KICK_ROOM_DESTROYED: i32 = 1;
// Cause 2 is real: the liveness sweep sends it immediately before close 4003, so a client
// that is alive enough to read (a suspended app coming back, a stalled network) can say
// why it was dropped instead of showing a bare socket error.
pub const KICK_IDLE_TIMEOUT: i32 = 2;
// ---------------------------------------------------------------------------
// Values and maps
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Null,
Int(i32),
Str(String),
Bool(bool),
Double(f64),
}
impl Value {
pub fn as_int(&self) -> Option<i32> {
match self {
Value::Int(v) => Some(*v),
_ => None,
}
}
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(v) => Some(v),
_ => None,
}
}
}
// An ordered key -> value bag. Ordering is not semantically meaningful on the wire, but
// keeping insertion order makes the changed-subset broadcasts arrive in the order the
// sender wrote them, which is one less thing for the client to reason about (and makes
// the tests deterministic).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Map(Vec<(String, Value)>);
impl Map {
pub fn new() -> Self {
Map(Vec::new())
}
// Duplicate keys collapse last-writer-wins, exactly like the Hashtable this mirrors.
// Convenience for the tests and for any caller building a bag from scratch; the
// relay itself only ever merges into an existing bag.
#[allow(dead_code)]
pub fn from_pairs<I, K>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, Value)>,
K: Into<String>,
{
let mut map = Map::new();
for (key, value) in pairs {
map.set(key, value);
}
map
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v)
}
pub fn get_int(&self, key: &str) -> Option<i32> {
self.get(key).and_then(Value::as_int)
}
pub fn get_str(&self, key: &str) -> Option<&str> {
self.get(key).and_then(Value::as_str)
}
// Last-writer-wins in place: an existing key keeps its position.
pub fn set<K: Into<String>>(&mut self, key: K, value: Value) {
let key = key.into();
match self.0.iter_mut().find(|(k, _)| *k == key) {
Some(slot) => slot.1 = value,
None => self.0.push((key, value)),
}
}
// Last-writer-wins merge of `other` INTO self. The relay uses it for property updates
// and for seeding/merging a joiner's bag out of the seating op's `playerProps`.
pub fn merge(&mut self, other: &Map) {
for (key, value) in other.iter() {
self.set(key, value.clone());
}
}
pub fn len(&self) -> usize {
self.0.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}
}
// ---------------------------------------------------------------------------
// Messages
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub enum ClientMsg {
Auth { user_id: String, token: String },
JoinLobby { name: String },
LeaveLobby,
// `props` is the ROOM bag; `player_props` is the joiner's own bag, mirroring Photon's
// OpCreateRoom/OpJoinRoom actorProperties. See docs/multi-live-ws-protocol.md,
// "Player properties at join time".
CreateRoom {
name: String,
max_players: u8,
visible: bool,
open: bool,
props: Map,
lobby_prop_keys: Vec<String>,
player_props: Map,
},
JoinRoom { name: String, player_props: Map },
JoinRandom { power_min: i32, power_max: i32, levels: Vec<Value>, player_props: Map },
LeaveRoom,
Rejoin { name: String, player_props: Map },
SetPlayerProps { props: Map },
SetRoomProps { props: Map },
Rpc { name: String, params: Vec<Value> },
Ping,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ServerMsg {
AuthOk { actorless_time_ms: f64 },
JoinedLobby,
LeftLobby,
JoinedRoom {
room_name: String,
your_actor: i32,
master_actor: i32,
room_props: Map,
players: Vec<(i32, Map)>,
},
CreateFailed { code: i32, msg: String },
JoinFailed { code: i32, msg: String },
PlayerEntered { actor: i32, props: Map },
PlayerLeft { actor: i32, inactive: bool },
LeftRoom,
RoomPropsChanged { props: Map },
PlayerPropsChanged { actor: i32, props: Map },
MasterSwitched { new_master_actor: i32 },
Rpc { sender_actor: i32, name: String, params: Vec<Value> },
Pong { server_time_ms: f64 },
// Sent by the liveness sweep with KICK_IDLE_TIMEOUT; KICK_ROOM_DESTROYED is reserved.
Kicked { cause: i32 },
}
// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub enum DecodeError {
// Frame ran out before the field did.
Truncated,
// Zero-length frame: there is not even an opcode.
Empty,
UnknownOp(u8),
UnknownTag(u8),
BadUtf8,
// "One frame = one message" - anything after the message is a framing bug.
TrailingBytes,
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
DecodeError::Truncated => write!(f, "truncated frame"),
DecodeError::Empty => write!(f, "empty frame"),
DecodeError::UnknownOp(op) => write!(f, "unknown opcode {}", op),
DecodeError::UnknownTag(tag) => write!(f, "unknown value tag {}", tag),
DecodeError::BadUtf8 => write!(f, "string is not valid utf-8"),
DecodeError::TrailingBytes => write!(f, "trailing bytes after message"),
}
}
}
struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
fn new(buf: &'a [u8]) -> Self {
Reader { buf, pos: 0 }
}
fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> {
let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?;
if end > self.buf.len() {
return Err(DecodeError::Truncated);
}
let out = &self.buf[self.pos..end];
self.pos = end;
Ok(out)
}
fn u8(&mut self) -> Result<u8, DecodeError> {
Ok(self.take(1)?[0])
}
fn bool(&mut self) -> Result<bool, DecodeError> {
// Photon booleans are one byte; anything non-zero is true.
Ok(self.u8()? != 0)
}
fn u16(&mut self) -> Result<u16, DecodeError> {
let b = self.take(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn i32(&mut self) -> Result<i32, DecodeError> {
let b = self.take(4)?;
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn f64(&mut self) -> Result<f64, DecodeError> {
let b = self.take(8)?;
Ok(f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]))
}
fn string(&mut self) -> Result<String, DecodeError> {
let len = self.u16()? as usize;
let bytes = self.take(len)?;
String::from_utf8(bytes.to_vec()).map_err(|_| DecodeError::BadUtf8)
}
fn value(&mut self) -> Result<Value, DecodeError> {
let tag = self.u8()?;
match tag {
TAG_NULL => Ok(Value::Null),
TAG_INT => Ok(Value::Int(self.i32()?)),
TAG_STRING => Ok(Value::Str(self.string()?)),
TAG_BOOL => Ok(Value::Bool(self.bool()?)),
TAG_DOUBLE => Ok(Value::Double(self.f64()?)),
other => Err(DecodeError::UnknownTag(other)),
}
}
fn map(&mut self) -> Result<Map, DecodeError> {
let count = self.u8()?;
let mut map = Map::new();
for _ in 0..count {
let key = self.string()?;
let value = self.value()?;
map.set(key, value);
}
Ok(map)
}
fn value_list(&mut self) -> Result<Vec<Value>, DecodeError> {
let count = self.u8()?;
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
out.push(self.value()?);
}
Ok(out)
}
fn string_list(&mut self) -> Result<Vec<String>, DecodeError> {
let count = self.u8()?;
let mut out = Vec::with_capacity(count as usize);
for _ in 0..count {
out.push(self.string()?);
}
Ok(out)
}
fn finish<T>(&self, value: T) -> Result<T, DecodeError> {
if self.pos != self.buf.len() {
return Err(DecodeError::TrailingBytes);
}
Ok(value)
}
}
pub fn decode_client(buf: &[u8]) -> Result<ClientMsg, DecodeError> {
let mut r = Reader::new(buf);
let op = r.u8().map_err(|_| DecodeError::Empty)?;
let msg = match op {
OP_AUTH => ClientMsg::Auth { user_id: r.string()?, token: r.string()? },
OP_JOIN_LOBBY => ClientMsg::JoinLobby { name: r.string()? },
OP_LEAVE_LOBBY => ClientMsg::LeaveLobby,
OP_CREATE_ROOM => ClientMsg::CreateRoom {
name: r.string()?,
max_players: r.u8()?,
visible: r.bool()?,
open: r.bool()?,
props: r.map()?,
lobby_prop_keys: r.string_list()?,
player_props: r.map()?,
},
OP_JOIN_ROOM => ClientMsg::JoinRoom { name: r.string()?, player_props: r.map()? },
OP_JOIN_RANDOM => ClientMsg::JoinRandom {
power_min: r.i32()?,
power_max: r.i32()?,
levels: r.value_list()?,
player_props: r.map()?,
},
OP_LEAVE_ROOM => ClientMsg::LeaveRoom,
OP_REJOIN => ClientMsg::Rejoin { name: r.string()?, player_props: r.map()? },
OP_SET_PLAYER_PROPS => ClientMsg::SetPlayerProps { props: r.map()? },
OP_SET_ROOM_PROPS => ClientMsg::SetRoomProps { props: r.map()? },
OP_RPC => ClientMsg::Rpc { name: r.string()?, params: r.value_list()? },
OP_PING => ClientMsg::Ping,
other => return Err(DecodeError::UnknownOp(other)),
};
r.finish(msg)
}
// The client's half of the codec. The relay never calls it; it is the executable
// reference the C# rewrite is written against, and what the round-trip tests drive.
#[allow(dead_code)]
pub fn decode_server(buf: &[u8]) -> Result<ServerMsg, DecodeError> {
let mut r = Reader::new(buf);
let op = r.u8().map_err(|_| DecodeError::Empty)?;
let msg = match op {
OP_AUTH_OK => ServerMsg::AuthOk { actorless_time_ms: r.f64()? },
OP_JOINED_LOBBY => ServerMsg::JoinedLobby,
OP_LEFT_LOBBY => ServerMsg::LeftLobby,
OP_JOINED_ROOM => {
let room_name = r.string()?;
let your_actor = r.i32()?;
let master_actor = r.i32()?;
let room_props = r.map()?;
let count = r.u8()?;
let mut players = Vec::with_capacity(count as usize);
for _ in 0..count {
let actor = r.i32()?;
players.push((actor, r.map()?));
}
ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players }
}
OP_CREATE_FAILED => ServerMsg::CreateFailed { code: r.i32()?, msg: r.string()? },
OP_JOIN_FAILED => ServerMsg::JoinFailed { code: r.i32()?, msg: r.string()? },
OP_PLAYER_ENTERED => ServerMsg::PlayerEntered { actor: r.i32()?, props: r.map()? },
OP_PLAYER_LEFT => ServerMsg::PlayerLeft { actor: r.i32()?, inactive: r.bool()? },
OP_LEFT_ROOM => ServerMsg::LeftRoom,
OP_ROOM_PROPS_CHANGED => ServerMsg::RoomPropsChanged { props: r.map()? },
OP_PLAYER_PROPS_CHANGED => {
ServerMsg::PlayerPropsChanged { actor: r.i32()?, props: r.map()? }
}
OP_MASTER_SWITCHED => ServerMsg::MasterSwitched { new_master_actor: r.i32()? },
OP_RPC_BROADCAST => ServerMsg::Rpc {
sender_actor: r.i32()?,
name: r.string()?,
params: r.value_list()?,
},
OP_PONG => ServerMsg::Pong { server_time_ms: r.f64()? },
OP_KICKED => ServerMsg::Kicked { cause: r.i32()? },
other => return Err(DecodeError::UnknownOp(other)),
};
r.finish(msg)
}
// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------
// The relay never legitimately produces a string past 64KiB or a count past 255 (keys
// are one or two characters, rooms hold four players, RPC params are a handful of ints),
// so encoding is infallible and the guards below only exist to keep a bug from emitting
// a frame the peer would mis-frame. They log and clamp rather than panic in a handler.
fn put_str(out: &mut Vec<u8>, s: &str) {
let mut bytes = s.as_bytes();
if bytes.len() > u16::MAX as usize {
println!("multi_live/ws: string of {} bytes truncated to fit u16 length", bytes.len());
let mut end = u16::MAX as usize;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
bytes = &s.as_bytes()[..end];
}
out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
out.extend_from_slice(bytes);
}
fn put_count(out: &mut Vec<u8>, count: usize, what: &str) -> usize {
let clamped = if count > u8::MAX as usize {
println!("multi_live/ws: {} count {} clamped to 255", what, count);
u8::MAX as usize
} else {
count
};
out.push(clamped as u8);
clamped
}
fn put_value(out: &mut Vec<u8>, value: &Value) {
match value {
Value::Null => out.push(TAG_NULL),
Value::Int(v) => {
out.push(TAG_INT);
out.extend_from_slice(&v.to_le_bytes());
}
Value::Str(v) => {
out.push(TAG_STRING);
put_str(out, v);
}
Value::Bool(v) => {
out.push(TAG_BOOL);
out.push(if *v { 1 } else { 0 });
}
Value::Double(v) => {
out.push(TAG_DOUBLE);
out.extend_from_slice(&v.to_le_bytes());
}
}
}
fn put_map(out: &mut Vec<u8>, map: &Map) {
let count = put_count(out, map.len(), "map");
for (key, value) in map.iter().take(count) {
put_str(out, key);
put_value(out, value);
}
}
fn put_value_list(out: &mut Vec<u8>, values: &[Value]) {
let count = put_count(out, values.len(), "value list");
for value in values.iter().take(count) {
put_value(out, value);
}
}
#[allow(dead_code)]
fn put_string_list(out: &mut Vec<u8>, values: &[String]) {
let count = put_count(out, values.len(), "string list");
for value in values.iter().take(count) {
put_str(out, value);
}
}
// See decode_server: the client's half, kept honest by the round-trip tests.
#[allow(dead_code)]
pub fn encode_client(msg: &ClientMsg) -> Vec<u8> {
let mut out = Vec::new();
match msg {
ClientMsg::Auth { user_id, token } => {
out.push(OP_AUTH);
put_str(&mut out, user_id);
put_str(&mut out, token);
}
ClientMsg::JoinLobby { name } => {
out.push(OP_JOIN_LOBBY);
put_str(&mut out, name);
}
ClientMsg::LeaveLobby => out.push(OP_LEAVE_LOBBY),
ClientMsg::CreateRoom { name, max_players, visible, open, props, lobby_prop_keys, player_props } => {
out.push(OP_CREATE_ROOM);
put_str(&mut out, name);
out.push(*max_players);
out.push(if *visible { 1 } else { 0 });
out.push(if *open { 1 } else { 0 });
put_map(&mut out, props);
put_string_list(&mut out, lobby_prop_keys);
put_map(&mut out, player_props);
}
ClientMsg::JoinRoom { name, player_props } => {
out.push(OP_JOIN_ROOM);
put_str(&mut out, name);
put_map(&mut out, player_props);
}
ClientMsg::JoinRandom { power_min, power_max, levels, player_props } => {
out.push(OP_JOIN_RANDOM);
out.extend_from_slice(&power_min.to_le_bytes());
out.extend_from_slice(&power_max.to_le_bytes());
put_value_list(&mut out, levels);
put_map(&mut out, player_props);
}
ClientMsg::LeaveRoom => out.push(OP_LEAVE_ROOM),
ClientMsg::Rejoin { name, player_props } => {
out.push(OP_REJOIN);
put_str(&mut out, name);
put_map(&mut out, player_props);
}
ClientMsg::SetPlayerProps { props } => {
out.push(OP_SET_PLAYER_PROPS);
put_map(&mut out, props);
}
ClientMsg::SetRoomProps { props } => {
out.push(OP_SET_ROOM_PROPS);
put_map(&mut out, props);
}
ClientMsg::Rpc { name, params } => {
out.push(OP_RPC);
put_str(&mut out, name);
put_value_list(&mut out, params);
}
ClientMsg::Ping => out.push(OP_PING),
}
out
}
pub fn encode_server(msg: &ServerMsg) -> Vec<u8> {
let mut out = Vec::new();
match msg {
ServerMsg::AuthOk { actorless_time_ms } => {
out.push(OP_AUTH_OK);
out.extend_from_slice(&actorless_time_ms.to_le_bytes());
}
ServerMsg::JoinedLobby => out.push(OP_JOINED_LOBBY),
ServerMsg::LeftLobby => out.push(OP_LEFT_LOBBY),
ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players } => {
out.push(OP_JOINED_ROOM);
put_str(&mut out, room_name);
out.extend_from_slice(&your_actor.to_le_bytes());
out.extend_from_slice(&master_actor.to_le_bytes());
put_map(&mut out, room_props);
let count = put_count(&mut out, players.len(), "player");
for (actor, props) in players.iter().take(count) {
out.extend_from_slice(&actor.to_le_bytes());
put_map(&mut out, props);
}
}
ServerMsg::CreateFailed { code, msg } => {
out.push(OP_CREATE_FAILED);
out.extend_from_slice(&code.to_le_bytes());
put_str(&mut out, msg);
}
ServerMsg::JoinFailed { code, msg } => {
out.push(OP_JOIN_FAILED);
out.extend_from_slice(&code.to_le_bytes());
put_str(&mut out, msg);
}
ServerMsg::PlayerEntered { actor, props } => {
out.push(OP_PLAYER_ENTERED);
out.extend_from_slice(&actor.to_le_bytes());
put_map(&mut out, props);
}
ServerMsg::PlayerLeft { actor, inactive } => {
out.push(OP_PLAYER_LEFT);
out.extend_from_slice(&actor.to_le_bytes());
out.push(if *inactive { 1 } else { 0 });
}
ServerMsg::LeftRoom => out.push(OP_LEFT_ROOM),
ServerMsg::RoomPropsChanged { props } => {
out.push(OP_ROOM_PROPS_CHANGED);
put_map(&mut out, props);
}
ServerMsg::PlayerPropsChanged { actor, props } => {
out.push(OP_PLAYER_PROPS_CHANGED);
out.extend_from_slice(&actor.to_le_bytes());
put_map(&mut out, props);
}
ServerMsg::MasterSwitched { new_master_actor } => {
out.push(OP_MASTER_SWITCHED);
out.extend_from_slice(&new_master_actor.to_le_bytes());
}
ServerMsg::Rpc { sender_actor, name, params } => {
out.push(OP_RPC_BROADCAST);
out.extend_from_slice(&sender_actor.to_le_bytes());
put_str(&mut out, name);
put_value_list(&mut out, params);
}
ServerMsg::Pong { server_time_ms } => {
out.push(OP_PONG);
out.extend_from_slice(&server_time_ms.to_le_bytes());
}
ServerMsg::Kicked { cause } => {
out.push(OP_KICKED);
out.extend_from_slice(&cause.to_le_bytes());
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn round_client(msg: ClientMsg) {
let bytes = encode_client(&msg);
let back = decode_client(&bytes).expect("client message decodes");
assert_eq!(back, msg);
assert_eq!(encode_client(&back), bytes);
}
fn round_server(msg: ServerMsg) {
let bytes = encode_server(&msg);
let back = decode_server(&bytes).expect("server message decodes");
assert_eq!(back, msg);
assert_eq!(encode_server(&back), bytes);
}
fn sample_map() -> Map {
Map::from_pairs(vec![
("A", Value::Int(-7)),
("B", Value::Str("ラブライブ".to_string())),
("C", Value::Bool(true)),
("D", Value::Double(0.5)),
("E", Value::Null),
])
}
#[test]
fn every_client_message_round_trips() {
round_client(ClientMsg::Auth { user_id: "12345".into(), token: "a-uuid".into() });
round_client(ClientMsg::JoinLobby { name: "MultiEventLobby_31".into() });
round_client(ClientMsg::LeaveLobby);
round_client(ClientMsg::CreateRoom {
name: "123456".into(),
max_players: 4,
visible: false,
open: true,
props: sample_map(),
lobby_prop_keys: vec!["C0".into(), "C1".into()],
player_props: sample_map(),
});
round_client(ClientMsg::JoinRoom {
name: "042719".into(),
player_props: sample_map(),
});
round_client(ClientMsg::JoinRandom {
power_min: 0,
power_max: i32::MAX,
levels: vec![Value::Int(1), Value::Int(4)],
player_props: sample_map(),
});
round_client(ClientMsg::LeaveRoom);
round_client(ClientMsg::Rejoin {
name: "1000000".into(),
player_props: sample_map(),
});
round_client(ClientMsg::SetPlayerProps { props: sample_map() });
round_client(ClientMsg::SetRoomProps { props: sample_map() });
round_client(ClientMsg::Rpc {
name: "SendStamp".into(),
params: vec![Value::Int(3), Value::Str("hi".into())],
});
round_client(ClientMsg::Ping);
}
#[test]
fn every_server_message_round_trips() {
round_server(ServerMsg::AuthOk { actorless_time_ms: 1_754_500_000_123.0 });
round_server(ServerMsg::JoinedLobby);
round_server(ServerMsg::LeftLobby);
round_server(ServerMsg::JoinedRoom {
room_name: "1000000".into(),
your_actor: 2,
master_actor: 1,
room_props: sample_map(),
players: vec![(1, sample_map()), (2, Map::new())],
});
round_server(ServerMsg::CreateFailed {
code: ERR_GAME_ID_ALREADY_EXISTS,
msg: "GameIdAlreadyExists".into(),
});
round_server(ServerMsg::JoinFailed { code: ERR_GAME_FULL, msg: "GameFull".into() });
round_server(ServerMsg::PlayerEntered { actor: 3, props: sample_map() });
round_server(ServerMsg::PlayerLeft { actor: 3, inactive: true });
round_server(ServerMsg::PlayerLeft { actor: 3, inactive: false });
round_server(ServerMsg::LeftRoom);
round_server(ServerMsg::RoomPropsChanged { props: sample_map() });
round_server(ServerMsg::PlayerPropsChanged { actor: 4, props: sample_map() });
round_server(ServerMsg::MasterSwitched { new_master_actor: 2 });
round_server(ServerMsg::Rpc {
sender_actor: 1,
name: "_MoveScene".into(),
params: vec![Value::Null],
});
round_server(ServerMsg::Pong { server_time_ms: -0.0 });
round_server(ServerMsg::Kicked { cause: KICK_ROOM_DESTROYED });
}
#[test]
fn empty_map_and_empty_strings_round_trip() {
round_client(ClientMsg::Auth { user_id: String::new(), token: String::new() });
round_client(ClientMsg::JoinLobby { name: String::new() });
round_client(ClientMsg::SetPlayerProps { props: Map::new() });
round_client(ClientMsg::Rpc { name: String::new(), params: Vec::new() });
round_client(ClientMsg::CreateRoom {
name: String::new(),
max_players: 0,
visible: true,
open: false,
props: Map::new(),
lobby_prop_keys: Vec::new(),
player_props: Map::new(),
});
// A joiner that has committed nothing yet sends an empty bag on every seating op.
round_client(ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() });
round_client(ClientMsg::Rejoin { name: String::new(), player_props: Map::new() });
round_client(ClientMsg::JoinRandom {
power_min: 0,
power_max: 0,
levels: Vec::new(),
player_props: Map::new(),
});
// The trailing playerProps of a JoinRoom is exactly one byte when empty.
assert_eq!(
encode_client(&ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() }),
vec![OP_JOIN_ROOM, 0, 0, 0]
);
round_server(ServerMsg::JoinedRoom {
room_name: String::new(),
your_actor: 1,
master_actor: 1,
room_props: Map::new(),
players: Vec::new(),
});
// An empty map is exactly one byte of payload.
assert_eq!(encode_client(&ClientMsg::SetRoomProps { props: Map::new() }), vec![OP_SET_ROOM_PROPS, 0]);
}
#[test]
fn max_u8_counts_round_trip() {
let map = Map::from_pairs((0..255).map(|i| (format!("k{}", i), Value::Int(i))));
assert_eq!(map.len(), 255);
round_client(ClientMsg::SetPlayerProps { props: map.clone() });
let params: Vec<Value> = (0..255).map(Value::Int).collect();
round_client(ClientMsg::Rpc { name: "SendStamp".into(), params });
let keys: Vec<String> = (0..255).map(|i| format!("k{}", i)).collect();
round_client(ClientMsg::CreateRoom {
name: "n".into(),
max_players: 255,
visible: true,
open: true,
props: map.clone(),
lobby_prop_keys: keys,
player_props: map.clone(),
});
// A full 255-key bag on each of the other three seating ops.
round_client(ClientMsg::JoinRoom { name: "n".into(), player_props: map.clone() });
round_client(ClientMsg::Rejoin { name: "n".into(), player_props: map.clone() });
round_client(ClientMsg::JoinRandom {
power_min: 0,
power_max: 1,
levels: vec![Value::Int(4)],
player_props: map,
});
let players: Vec<(i32, Map)> = (0..255).map(|i| (i, Map::new())).collect();
round_server(ServerMsg::JoinedRoom {
room_name: "n".into(),
your_actor: 1,
master_actor: 1,
room_props: Map::new(),
players,
});
}
#[test]
fn integer_and_double_extremes_survive() {
round_client(ClientMsg::JoinRandom {
power_min: i32::MIN,
power_max: i32::MAX,
levels: vec![Value::Int(i32::MIN), Value::Int(i32::MAX), Value::Int(0)],
player_props: Map::new(),
});
round_server(ServerMsg::PlayerEntered {
actor: i32::MIN,
props: Map::from_pairs(vec![
("a", Value::Double(f64::MAX)),
("b", Value::Double(f64::MIN_POSITIVE)),
("c", Value::Double(f64::INFINITY)),
]),
});
}
#[test]
fn wire_layout_is_byte_exact() {
// Guard the framing itself: little-endian everywhere, u16 string lengths,
// u8 counts, tag-then-payload values. If this test needs updating, the C#
// client needs updating too.
assert_eq!(
encode_client(&ClientMsg::Auth { user_id: "7".into(), token: "ab".into() }),
vec![OP_AUTH, 1, 0, b'7', 2, 0, b'a', b'b']
);
assert_eq!(
encode_client(&ClientMsg::JoinRandom {
power_min: 1,
power_max: 256,
levels: vec![Value::Int(4)],
player_props: Map::new(),
}),
// ... levels ValueList ..., then the trailing empty playerProps map (count 0).
vec![OP_JOIN_RANDOM, 1, 0, 0, 0, 0, 1, 0, 0, 1, TAG_INT, 4, 0, 0, 0, 0]
);
// The seating ops' trailing bag is an ordinary Map: count:u8 then (key,value)*.
assert_eq!(
encode_client(&ClientMsg::JoinRoom {
name: "42".into(),
player_props: Map::from_pairs(vec![("B", Value::Str("7".into()))]),
}),
vec![OP_JOIN_ROOM, 2, 0, b'4', b'2', 1, 1, 0, b'B', TAG_STRING, 1, 0, b'7']
);
assert_eq!(
encode_client(&ClientMsg::Rejoin {
name: "42".into(),
player_props: Map::from_pairs(vec![("F", Value::Int(5))]),
}),
vec![OP_REJOIN, 2, 0, b'4', b'2', 1, 1, 0, b'F', TAG_INT, 5, 0, 0, 0]
);
assert_eq!(
encode_client(&ClientMsg::SetPlayerProps {
props: Map::from_pairs(vec![("LB", Value::Int(12))]),
}),
vec![OP_SET_PLAYER_PROPS, 1, 2, 0, b'L', b'B', TAG_INT, 12, 0, 0, 0]
);
assert_eq!(
encode_server(&ServerMsg::PlayerLeft { actor: 2, inactive: true }),
vec![OP_PLAYER_LEFT, 2, 0, 0, 0, 1]
);
assert_eq!(
encode_server(&ServerMsg::AuthOk { actorless_time_ms: 1.0 }),
vec![OP_AUTH_OK, 0, 0, 0, 0, 0, 0, 0xf0, 0x3f]
);
}
#[test]
fn malformed_frames_are_rejected() {
assert_eq!(decode_client(&[]), Err(DecodeError::Empty));
assert_eq!(decode_client(&[99]), Err(DecodeError::UnknownOp(99)));
assert_eq!(decode_server(&[99]), Err(DecodeError::UnknownOp(99)));
// JoinLobby with a length prefix longer than the payload.
assert_eq!(decode_client(&[OP_JOIN_LOBBY, 4, 0, b'a']), Err(DecodeError::Truncated));
// Ping carries nothing.
assert_eq!(decode_client(&[OP_PING, 0]), Err(DecodeError::TrailingBytes));
// Unknown value tag inside a map.
assert_eq!(
decode_client(&[OP_SET_ROOM_PROPS, 1, 1, 0, b'A', 9]),
Err(DecodeError::UnknownTag(9))
);
// A map that promises two entries but carries one.
assert_eq!(
decode_client(&[OP_SET_ROOM_PROPS, 2, 1, 0, b'A', TAG_NULL]),
Err(DecodeError::Truncated)
);
// Invalid UTF-8 in a string.
assert_eq!(decode_client(&[OP_JOIN_ROOM, 1, 0, 0xff]), Err(DecodeError::BadUtf8));
}
#[test]
fn duplicate_map_keys_collapse_last_writer_wins() {
let bytes = vec![
OP_SET_ROOM_PROPS, 2,
1, 0, b'A', TAG_INT, 1, 0, 0, 0,
1, 0, b'A', TAG_INT, 2, 0, 0, 0,
];
let ClientMsg::SetRoomProps { props } = decode_client(&bytes).unwrap() else {
panic!("expected SetRoomProps");
};
assert_eq!(props.len(), 1);
assert_eq!(props.get_int("A"), Some(2));
}
#[test]
fn map_set_is_last_writer_wins_in_place() {
let mut map = Map::from_pairs(vec![("A", Value::Int(1)), ("B", Value::Int(2))]);
map.set("A", Value::Int(9));
assert_eq!(map.len(), 2);
assert_eq!(
map.iter().map(|(k, _)| k).collect::<Vec<_>>(),
vec!["A", "B"]
);
assert_eq!(map.get_int("A"), Some(9));
}
#[test]
fn bool_payload_accepts_any_nonzero() {
// Photon writes 0/1; be lenient reading, strict writing.
assert_eq!(
decode_server(&[OP_PLAYER_LEFT, 1, 0, 0, 0, 7]),
Ok(ServerMsg::PlayerLeft { actor: 1, inactive: true })
);
assert_eq!(
encode_server(&ServerMsg::PlayerLeft { actor: 1, inactive: true })[5],
1
);
}
}

File diff suppressed because it is too large Load Diff

367
src/router/multi_live/ws.rs Normal file
View File

@@ -0,0 +1,367 @@
// WebSocket end of the multi-live relay: GET /api/multi_live/ws.
//
// One task per connection reads frames, decodes them and drives the (synchronous,
// lock-serialised) registry in rooms.rs. A second task per connection drains that
// connection's unbounded queue into the socket. Nothing that touches the registry ever
// awaits while the lock is held, which is what lets the registry hand out a total order
// over every broadcast - see the header comment in rooms.rs.
//
// Everything the client can get wrong ends in a close: 4000 for a framing/protocol
// error, 4001 for an unauthenticated or unauthenticated-first frame, 4002 for blowing
// the inbound rate cap, 4003 for going silent long enough to be presumed dead (the
// liveness sweep - see LIVENESS_TIMEOUT in rooms.rs, and start_sweeper below).
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use actix_web::rt;
use actix_web::{web, HttpRequest, HttpResponse};
use actix_ws::{AggregatedMessage, AggregatedMessageStream, CloseCode, CloseReason, Session};
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use crate::router::userdata;
use super::proto::{
self, ClientMsg, DecodeError, ServerMsg, CLOSE_PROTOCOL, CLOSE_RATE_LIMIT,
CLOSE_UNAUTHENTICATED,
};
use super::rooms::{self, ConnId, Outbound};
// "Server closes idle unauthenticated connections after 10s."
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
// "a per-connection cap of 30 inbound messages/sec ... over the cap -> close 4002".
// The honest client peaks at 3-5/sec.
const RATE_LIMIT_PER_SEC: usize = 30;
const RATE_WINDOW: Duration = Duration::from_secs(1);
// Property bags and RPC params are tens of bytes; nothing legitimate comes close.
const MAX_FRAME_BYTES: usize = 64 * 1024;
// How often the inactive-actor, empty-room and connection-liveness expiries are checked.
const SWEEP_INTERVAL: Duration = Duration::from_secs(1);
pub async fn ws(req: HttpRequest, body: web::Payload) -> Result<HttpResponse, actix_web::Error> {
// Older client builds carry incompatible multi implementations (pre-relay wire
// framing), so the upgrade itself is gated on the extended-protocol version — a
// non-101 answer makes the client's ConnectAsync fail cleanly into its official
// disconnect flow instead of mis-framing against the relay.
let protocol = crate::router::global::client_protocol_version(&req);
if protocol < super::PROTOCOL_VERSION {
println!(
"multi_live/ws: upgrade refused, X-Protocol-Version {} < {}",
protocol,
super::PROTOCOL_VERSION
);
return Ok(HttpResponse::UpgradeRequired().finish());
}
let (response, session, stream) = match actix_ws::handle(&req, body) {
Ok(parts) => {
println!("multi_live/ws: upgrade accepted, awaiting Auth");
parts
}
Err(err) => {
println!("multi_live/ws: upgrade REJECTED (not a websocket handshake?): {}", err);
return Err(err);
}
};
let stream = stream
.max_frame_size(MAX_FRAME_BYTES)
.aggregate_continuations()
.max_continuation_size(MAX_FRAME_BYTES);
let (tx, rx) = unbounded_channel::<Outbound>();
rt::spawn(writer(session, rx));
rt::spawn(reader(stream, tx));
Ok(response)
}
// The expiry timers: held-slot TTL, empty-room TTL and the connection liveness window.
//
// One task for the whole process, started from run_server so it lives on the system
// arbiter. It used to be lazily started by the first WebSocket upgrade instead, which put
// it on whichever HTTP worker happened to serve that upgrade: a panic anywhere in that
// worker took the sweeper down with it, permanently and silently, and `Once` guaranteed
// nothing would ever start it again. Every room in the process would then hold its seats
// forever.
//
// Called once per run_server, unconditionally and not behind a "started already" flag:
// each run_server builds its own actix System, and a task spawned on the previous one died
// with it (the mobile path stops and restarts the server in-process). A flag would leave
// the restarted server with no sweeper at all; two sweepers, if they ever overlapped,
// would only mean the idempotent sweep runs twice a second.
pub fn start_sweeper() {
rt::spawn(async {
let mut ticker = rt::time::interval(SWEEP_INTERVAL);
loop {
ticker.tick().await;
// The guard is a temporary: it is released before the next await.
rooms::registry().sweep(Instant::now());
}
});
}
// Drains this connection's FIFO queue to the socket. The queue is unbounded so that the
// registry can push a whole broadcast while holding its lock without ever blocking.
async fn writer(mut session: Session, mut rx: UnboundedReceiver<Outbound>) {
while let Some(out) = rx.recv().await {
match out {
Outbound::Msg(msg) => {
if session.binary(proto::encode_server(&msg)).await.is_err() {
return;
}
}
Outbound::Pong(bytes) => {
if session.pong(&bytes).await.is_err() {
return;
}
}
Outbound::Close(code) => {
let _ = session
.close(Some(CloseReason { code: CloseCode::Other(code), description: None }))
.await;
return;
}
}
}
// Queue closed: the connection was deregistered, so the socket goes with it.
let _ = session.close(None).await;
}
async fn reader(mut stream: AggregatedMessageStream, tx: UnboundedSender<Outbound>) {
let Some(id) = authenticate(&mut stream, &tx).await else {
return;
};
let mut rate = RateLimiter::new();
loop {
let Some(frame) = stream.recv().await else {
break;
};
let payload = match frame {
Ok(AggregatedMessage::Binary(bytes)) => bytes,
Ok(AggregatedMessage::Ping(bytes)) => {
// WebSocket-level keepalive, unrelated to the protocol's Ping op. It is
// still proof the socket is alive, so it counts for the liveness window
// (the protocol ops are touched inside registry.handle).
rooms::registry().touch(id, Instant::now());
let _ = tx.send(Outbound::Pong(bytes.to_vec()));
continue;
}
Ok(AggregatedMessage::Pong(_)) => {
rooms::registry().touch(id, Instant::now());
continue;
}
Ok(AggregatedMessage::Close(_)) => break,
Ok(AggregatedMessage::Text(_)) => {
// "binary frames only ... Text frames are a protocol error -> close".
println!("multi_live/ws: text frame from conn {}", id);
close(&tx, CLOSE_PROTOCOL);
break;
}
Err(err) => {
println!("multi_live/ws: websocket error on conn {}: {}", id, err);
break;
}
};
let now = Instant::now();
if !rate.allow(now) {
println!("multi_live/ws: conn {} exceeded {} msg/sec", id, RATE_LIMIT_PER_SEC);
close(&tx, CLOSE_RATE_LIMIT);
break;
}
let msg = match proto::decode_client(&payload) {
Ok(msg) => msg,
Err(err) => {
println!("multi_live/ws: bad frame from conn {}: {}", id, err);
close(&tx, close_code_for(&err));
break;
}
};
if matches!(msg, ClientMsg::Auth { .. }) {
// Auth is the first message and only the first message.
println!("multi_live/ws: repeat Auth on conn {}", id);
close(&tx, CLOSE_PROTOCOL);
break;
}
rooms::registry().handle(id, msg, now);
}
rooms::registry().disconnect(id, Instant::now());
}
// Reads the mandatory first frame and registers the connection. Returns None when the
// connection was closed instead.
async fn authenticate(
stream: &mut AggregatedMessageStream,
tx: &UnboundedSender<Outbound>,
) -> Option<ConnId> {
let first = match rt::time::timeout(AUTH_TIMEOUT, stream.recv()).await {
Ok(Some(Ok(frame))) => frame,
Ok(Some(Err(_))) | Ok(None) => return None,
Err(_) => {
println!("multi_live/ws: no Auth within {}s", AUTH_TIMEOUT.as_secs());
close(tx, CLOSE_UNAUTHENTICATED);
return None;
}
};
let AggregatedMessage::Binary(payload) = first else {
// "First message on a connection MUST be Auth; anything else -> close 4001."
close(tx, CLOSE_UNAUTHENTICATED);
return None;
};
let Ok(ClientMsg::Auth { user_id, token }) = proto::decode_client(&payload) else {
println!("multi_live/ws: first message was not a decodable Auth frame");
close(tx, CLOSE_UNAUTHENTICATED);
return None;
};
let Some(uid) = resolve_user(&user_id, &token) else {
// Never print the token itself (it is the login credential); empty-vs-unknown is
// the diagnostic that matters: empty means the client sent no credential at all
// (the pre-fix Android builds sent UserSaveData.m_uuid, which device builds never
// populate), unknown means a credential the tokens table has no row for.
println!(
"multi_live/ws: rejecting uid {}: bad session ({})",
user_id,
if token.is_empty() { "empty token" } else { "unknown token" }
);
close(tx, CLOSE_UNAUTHENTICATED);
return None;
};
let id = rooms::registry().connect(uid, tx.clone(), Instant::now());
println!("multi_live/ws: auth ok, uid {} connected as conn {}", uid, id);
let _ = tx.send(Outbound::Msg(ServerMsg::AuthOk {
actorless_time_ms: rooms::server_time_ms(),
}));
Some(id)
}
// The relay validates the same credential the HTTP layer does. Over HTTP the login token
// arrives inside the `a6573cbe` header (global::get_login) and every handler then keys
// userdata off it; here it arrives as the Auth payload instead, and the tokens table maps
// it back to the account. The claimed userId only has to agree with the token, so a
// client cannot relay as somebody else - which is a little stricter than the HTTP layer,
// where an unknown token simply resolves to an empty account.
fn resolve_user(user_id: &str, token: &str) -> Option<i64> {
if token.is_empty() {
return None;
}
match_claim(user_id, userdata::uid_from_login_token(token))
}
// The claim check, split out from the lookup so it can be tested without a database.
// `uid` is 0 when the token is unknown.
fn match_claim(user_id: &str, uid: i64) -> Option<i64> {
if uid == 0 {
return None;
}
match user_id.parse::<i64>() {
// A blank or unparsable userId is tolerated: the token is the authority.
Err(_) => Some(uid),
Ok(claimed) if claimed == 0 || claimed == uid => Some(uid),
Ok(_) => None,
}
}
fn close_code_for(err: &DecodeError) -> u16 {
// The spec names 4001 and 4002 only; every framing failure shares 4000.
match err {
DecodeError::Empty
| DecodeError::Truncated
| DecodeError::TrailingBytes
| DecodeError::BadUtf8
| DecodeError::UnknownOp(_)
| DecodeError::UnknownTag(_) => CLOSE_PROTOCOL,
}
}
fn close(tx: &UnboundedSender<Outbound>, code: u16) {
let _ = tx.send(Outbound::Close(code));
}
// Sliding one second window rather than a fixed bucket, so 30 messages either side of a
// second boundary still trips the cap.
struct RateLimiter {
seen: VecDeque<Instant>,
}
impl RateLimiter {
fn new() -> Self {
RateLimiter { seen: VecDeque::with_capacity(RATE_LIMIT_PER_SEC + 1) }
}
fn allow(&mut self, now: Instant) -> bool {
while let Some(front) = self.seen.front() {
if now.duration_since(*front) >= RATE_WINDOW {
self.seen.pop_front();
} else {
break;
}
}
if self.seen.len() >= RATE_LIMIT_PER_SEC {
return false;
}
self.seen.push_back(now);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limiter_allows_the_cap_and_rejects_the_next() {
let mut rate = RateLimiter::new();
let start = Instant::now();
for i in 0..RATE_LIMIT_PER_SEC {
assert!(rate.allow(start + Duration::from_millis(i as u64)), "message {} refused", i);
}
assert!(!rate.allow(start + Duration::from_millis(999)));
// The window slides: once the first message ages out there is room again.
assert!(rate.allow(start + Duration::from_millis(1001)));
}
#[test]
fn rate_limiter_catches_a_burst_straddling_a_second_boundary() {
let mut rate = RateLimiter::new();
let start = Instant::now();
// 29 messages at the end of one second...
for i in 0..RATE_LIMIT_PER_SEC - 1 {
assert!(rate.allow(start + Duration::from_millis(900 + i as u64)));
}
// ...and two more just after the boundary is still 31 inside one second.
assert!(rate.allow(start + Duration::from_millis(1000)));
assert!(!rate.allow(start + Duration::from_millis(1001)));
}
#[test]
fn every_decode_failure_closes_with_the_protocol_code() {
for err in [
DecodeError::Empty,
DecodeError::Truncated,
DecodeError::TrailingBytes,
DecodeError::BadUtf8,
DecodeError::UnknownOp(200),
DecodeError::UnknownTag(9),
] {
assert_eq!(close_code_for(&err), CLOSE_PROTOCOL);
}
}
#[test]
fn auth_needs_a_token_that_agrees_with_the_claimed_user() {
// An empty token never reaches the database.
assert_eq!(resolve_user("1", ""), None);
// An unknown token resolves to uid 0.
assert_eq!(match_claim("1", 0), None);
assert_eq!(match_claim("42", 42), Some(42));
// Claiming somebody else's account with your own token is refused.
assert_eq!(match_claim("43", 42), None);
// A blank, zero or unparsable userId defers to the token.
assert_eq!(match_claim("", 42), Some(42));
assert_eq!(match_claim("0", 42), Some(42));
assert_eq!(match_claim("not-a-number", 42), Some(42));
}
}

View File

@@ -1,4 +1,4 @@
use jzon::{object, array}; use jzon::object;
use actix_web::{web, Responder}; use actix_web::{web, Responder};
use crate::router::Api; use crate::router::Api;
@@ -15,5 +15,8 @@ async fn reward() -> impl Responder {
} }
async fn reward_post() -> impl Responder { async fn reward_post() -> impl Responder {
Api(Some(array![])) Api(Some(object!{
"gift_list": [],
"reward_list": []
}))
} }

View File

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

View File

@@ -246,6 +246,13 @@ fn get_uid(token: &str) -> i64 {
data.parse::<i64>().unwrap_or(0) 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)
}
// Needed by gree // Needed by gree
pub fn get_login_token(uid: i64) -> String { pub fn get_login_token(uid: i64) -> String {
let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid)); let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid));
@@ -475,6 +482,47 @@ pub fn save_acc_eventlogin(auth_key: &str, data: JsonValue) {
pub fn save_server_data(auth_key: &str, data: JsonValue) { pub fn save_server_data(auth_key: &str, data: JsonValue) {
save_data(auth_key, "server_data", data); 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| {
let raw: String = conn.query_row(
"SELECT server_data FROM server_data WHERE user_id=?1",
params!(key),
|row| row.get(0)
)?;
let mut data = jzon::parse(&raw).unwrap_or(JsonValue::Null);
let rv = f(&mut data);
conn.execute(
"UPDATE server_data SET server_data=?1 WHERE user_id=?2",
params!(jzon::stringify(data), key)
)?;
Ok(rv)
});
match rv {
Ok(rv) => rv,
Err(err) => {
println!("modify_server_data: {} for user {}", err, key);
T::default()
}
}
}
pub fn save_acc_chats(auth_key: &str, data: JsonValue) { pub fn save_acc_chats(auth_key: &str, data: JsonValue) {
save_data(auth_key, "chats", data); save_data(auth_key, "chats", data);
} }

View File

@@ -1,13 +1,29 @@
{ {
"point_ranking": { "point_ranking": {
"rank": 0,
"point": 0 "point": 0
}, },
"score_ranking": [], "score_ranking": {
"member_ranking": [], "all_rank": 0,
"lottery_box": [], "group_rank": 0,
"score": 0
},
"member_ranking": {
"master_character_id": 0,
"rank": 0,
"point": 0
},
"lottery_box": {
"master_lottery_id": 0,
"reset_count": 0,
"draw_count_list": []
},
"mission_list": [], "mission_list": [],
"policy_agreement": 0, "policy_agreement": 0,
"incentive_lottery": 0, "incentive_lottery": 0,
"is_disconnected": 0,
"help_count": 0,
"penalty_remaining_time": 0,
"star_event": { "star_event": {
"star_level": 0, "star_level": 0,
"last_event_star_level": 0, "last_event_star_level": 0,

View File

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

View File

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

View File

@@ -229,6 +229,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse {
account_import: get_config()["import"].as_bool().unwrap(), account_import: get_config()["import"].as_bool().unwrap(),
custom_songs: !crate::router::custom_song::disabled(), custom_songs: !crate::router::custom_song::disabled(),
custom_cards: !crate::router::custom_card::disabled(), custom_cards: !crate::router::custom_card::disabled(),
custom_3dmv: !crate::router::custom_3dmv::disabled(),
links: { links: {
global: args.global_android, global: args.global_android,
japan: args.japan_android, japan: args.japan_android,
@@ -367,9 +368,6 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse {
} }
lazy_static! { lazy_static! {
// The selectable character list for the custom-card form: every official
// and SIF1-imported character in the baked csv, by name. The import band
// starts at 5001 (5001-5172 + 6001-6009); official ids top out at 4014
static ref CHARACTER_CHOICES: JsonValue = { static ref CHARACTER_CHOICES: JsonValue = {
let mut rv = jzon::array![]; let mut rv = jzon::array![];
for row in crate::router::databases::csv::table(Region::Jp, "character").members() { for row in crate::router::databases::csv::table(Region::Jp, "character").members() {
@@ -384,8 +382,6 @@ lazy_static! {
rv rv
}; };
// The skill_center table with its display strings, for picking a center
// skill by name instead of by raw id
static ref SKILL_CENTER_CHOICES: JsonValue = { static ref SKILL_CENTER_CHOICES: JsonValue = {
let mut en_rows = object!{}; let mut en_rows = object!{};
for row in crate::router::databases::csv::table(Region::En, "skill_center").members() { for row in crate::router::databases::csv::table(Region::En, "skill_center").members() {
@@ -406,9 +402,6 @@ lazy_static! {
}; };
} }
// The characters a card upload may reference, for the webui's searchable
// picker: the baked official + imported list, plus the custom characters
// this session may build on (their own and the publicly visible ones)
pub fn list_characters(req: HttpRequest) -> HttpResponse { pub fn list_characters(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
@@ -446,8 +439,6 @@ pub fn list_skill_centers(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The concrete upload bounds (per-rarity stat caps, enum ranges, skill array
// lengths) so the form enforces them before submitting
pub fn custom_card_limits(req: HttpRequest) -> HttpResponse { pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
if session_uid(&req).is_none() { if session_uid(&req).is_none() {
return error("Not logged in"); return error("Not logged in");
@@ -461,8 +452,22 @@ pub fn custom_card_limits(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The requesting user's own effective scopes, for webui nav gating. Any pub fn custom_3dmv_limits(req: HttpRequest) -> HttpResponse {
// session may ask - it only ever reveals what the user themselves holds if crate::router::custom_3dmv::disabled() {
return HttpResponse::NotFound().finish();
}
if session_uid(&req).is_none() {
return error("Not logged in");
}
let resp = object!{
result: "OK",
data: crate::router::custom_3dmv::upload_limits()
};
HttpResponse::Ok()
.insert_header(ContentType::json())
.body(jzon::stringify(resp))
}
pub fn my_scopes(req: HttpRequest) -> HttpResponse { pub fn my_scopes(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
@@ -471,12 +476,14 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
result: "OK", result: "OK",
data: { data: {
uid: uid, uid: uid,
scopes: permissions::scopes_for(uid), scopes: permissions::get_user_permissions(uid),
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD), can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH), can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT), can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|| permissions::has(uid, permissions::PERMISSION_REVOKE) || permissions::has(uid, permissions::PERMISSION_REVOKE),
can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE)
} }
}; };
HttpResponse::Ok() HttpResponse::Ok()
@@ -484,8 +491,6 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// The admin view: every grant plus the grantable vocabulary. Needs a
// permission.* scope - my_scopes is the anyone-can-ask endpoint
pub fn list_permissions(req: HttpRequest) -> HttpResponse { pub fn list_permissions(req: HttpRequest) -> HttpResponse {
let Some(uid) = session_uid(&req) else { let Some(uid) = session_uid(&req) else {
return error("Not logged in"); return error("Not logged in");
@@ -505,7 +510,7 @@ pub fn list_permissions(req: HttpRequest) -> HttpResponse {
uid: uid, uid: uid,
can_grant: can_grant, can_grant: can_grant,
can_revoke: can_revoke, can_revoke: can_revoke,
scopes: permissions::scopes_for(uid), scopes: permissions::get_user_permissions(uid),
available: available, available: available,
grants: permissions::grants() grants: permissions::grants()
} }
@@ -592,6 +597,11 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse {
.body(jzon::stringify(resp)) .body(jzon::stringify(resp))
} }
// rest of file is tests that ai wrote
// I didn't read through them because I don't super care about tests but they probably do something
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -25,6 +25,7 @@ pub struct HostConfig {
pub en_android_asset_hash: String, pub en_android_asset_hash: String,
pub enable_custom_songs: bool, pub enable_custom_songs: bool,
pub enable_custom_cards: bool, pub enable_custom_cards: bool,
pub enable_custom_3dmv: bool,
} }
// Lets an embedding app (or the tests) enable the opt-in custom songs feature // Lets an embedding app (or the tests) enable the opt-in custom songs feature
@@ -37,6 +38,10 @@ pub fn set_enable_custom_cards(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_cards = enabled; HOST_CONFIG.write().unwrap().enable_custom_cards = enabled;
} }
pub fn set_enable_custom_3dmv(enabled: bool) {
HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled;
}
// The --owner uids: the permission system's bootstrap grantors. Process-level // The --owner uids: the permission system's bootstrap grantors. Process-level
// state rather than db rows so they work on a fresh install and can't be // state rather than db rows so they work on a fresh install and can't be
// revoked through the webui // revoked through the webui
@@ -162,14 +167,17 @@ pub fn overlay_args(args: &mut crate::options::Args) {
} }
overlay_str!(jp_android_asset_hash); overlay_str!(jp_android_asset_hash);
overlay_str!(en_android_asset_hash); overlay_str!(en_android_asset_hash);
// Overlay only ever enables the features; a command-line --enable-custom-songs // Overlay only ever enables the features; a command-line --enable-custom-*
// / --enable-custom-cards is never overridden back to off // flag is never overridden back to off
if cfg.enable_custom_songs { if cfg.enable_custom_songs {
args.enable_custom_songs = true; args.enable_custom_songs = true;
} }
if cfg.enable_custom_cards { if cfg.enable_custom_cards {
args.enable_custom_cards = true; args.enable_custom_cards = true;
} }
if cfg.enable_custom_3dmv {
args.enable_custom_3dmv = true;
}
} }
// idk why an ai put tests here but they are here now. Yay tests???? // idk why an ai put tests here but they are here now. Yay tests????
@@ -192,5 +200,6 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> {
// while holding the lock // while holding the lock
set_enable_custom_songs(true); set_enable_custom_songs(true);
set_enable_custom_cards(true); set_enable_custom_cards(true);
set_enable_custom_3dmv(true);
guard guard
} }

View File

@@ -59,4 +59,28 @@ impl SQLite {
} }
Ok(rv) 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 tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let rv = f(&tx)?;
tx.commit()?;
Ok(rv)
}
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

2
webui

Submodule webui updated: 700dae60a3...c17a5b00dc