From 971aa32361d6429570f030812a081ad56100aec8 Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Fri, 28 Aug 2026 21:40:26 -0500 Subject: [PATCH] Add macos route and some other tests I guess --- src/database/custom_card.rs | 42 ++++++++++-- src/database/custom_song.rs | 17 +++-- src/options.rs | 6 ++ src/router/custom_3dmv.rs | 9 +++ src/router/custom_card.rs | 3 + src/router/custom_song.rs | 126 ++++++++++++++++++++++++++++++++++++ src/router/global.rs | 36 ++++++++++- src/router/userdata/mod.rs | 8 ++- src/static_handlers.rs | 2 +- 9 files changed, 235 insertions(+), 14 deletions(-) diff --git a/src/database/custom_card.rs b/src/database/custom_card.rs index b32a05f..b252861 100644 --- a/src/database/custom_card.rs +++ b/src/database/custom_card.rs @@ -350,7 +350,10 @@ pub fn get_browse_cards() -> JsonValue { // considered, and ids are never reused, so official (or imported) cards can't // come back from this and a wipe is final. A card that's merely unpublished // still has its row - only genuinely deleted ids are returned -pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue { +// None = the catalog could not be read (or is entirely empty while players still hold +// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly +// like one where every id is dead, and get_acc saves the pruned userdata. +pub fn dead_card_ids(candidates: &JsonValue) -> Option { let mut ids: Vec = Vec::new(); for id in candidates.members() { let Some(id) = id.as_i64() else { continue; }; @@ -359,17 +362,23 @@ pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue { } } if ids.is_empty() { - return array![]; + return Some(array![]); } let list = ids.iter().map(|id| id.to_string()).collect::>().join(","); - let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).unwrap_or(array![]); + let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).ok()?; + if alive.is_empty() { + let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM cards", params!()).ok()?; + if total == 0 { + return None; + } + } let mut rv = array![]; for id in ids { if !alive.contains(id) { rv.push(id).unwrap(); } } - rv + Some(rv) } // The published + obtainable pool the custom gacha banner draws from, per @@ -596,10 +605,33 @@ mod tests { insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false).unwrap(); delete_card(dead); - let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]); + let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]).unwrap(); assert_eq!(dead_ids.len(), 1); assert_eq!(dead_ids[0].as_i64(), Some(dead)); wipe(3008); } + + // An unreadable or blank catalog must never read as "every custom card is + // dead": get_acc prunes on that answer and saves the pruned userdata + #[test] + fn dead_ids_are_unknown_when_the_catalog_cannot_be_read() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(3009); + let alive = next_card_id(); + insert_card(alive, 1001, 3009, &card_blob(alive, 1), false, false).unwrap(); + + let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap(); + conn.execute("ALTER TABLE cards RENAME TO cards_hidden", ()).unwrap(); + let unreadable = dead_card_ids(&array![alive]); + conn.execute("CREATE TABLE cards (master_card_id BIGINT NOT NULL PRIMARY KEY, master_character_id BIGINT NOT NULL, owner_id BIGINT NOT NULL, card TEXT NOT NULL, rarity INT NOT NULL DEFAULT 1, published INT NOT NULL DEFAULT 0, obtainable INT NOT NULL DEFAULT 0)", ()).unwrap(); + let blank = dead_card_ids(&array![alive]); + conn.execute("DROP TABLE cards", ()).unwrap(); + conn.execute("ALTER TABLE cards_hidden RENAME TO cards", ()).unwrap(); + + assert!(unreadable.is_none(), "an unreadable catalog reported dead cards"); + assert!(blank.is_none(), "a blank catalog reported every card dead"); + assert_eq!(dead_card_ids(&array![alive]).unwrap().len(), 0); + wipe(3009); + } } diff --git a/src/database/custom_song.rs b/src/database/custom_song.rs index 8b98d76..dc2b155 100644 --- a/src/database/custom_song.rs +++ b/src/database/custom_song.rs @@ -365,7 +365,10 @@ pub fn non_public_music_ids_for(user_id: i64) -> JsonValue { // range is ever considered, so official songs can't come back from this. A song // that's merely private/shared still has its row - only genuinely deleted ids // (which are never reused) are returned -pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue { +// None = the catalog could not be read (or is entirely empty while players still hold +// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly +// like one where every id is dead, and get_acc saves the pruned userdata. +pub fn dead_music_ids(candidates: &JsonValue) -> Option { let mut ids: Vec = Vec::new(); for id in candidates.members() { let Some(id) = id.as_i64() else { continue; }; @@ -374,17 +377,23 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue { } } if ids.is_empty() { - return array![]; + return Some(array![]); } let list = ids.iter().map(|id| id.to_string()).collect::>().join(","); - let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).unwrap_or(array![]); + let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).ok()?; + if alive.is_empty() { + let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM songs", params!()).ok()?; + if total == 0 { + return None; + } + } let mut rv = array![]; for id in ids { if !alive.contains(id) { rv.push(id).unwrap(); } } - rv + Some(rv) } // Every stored catalog blob, unparsed and unfiltered by visibility. Only the diff --git a/src/options.rs b/src/options.rs index b487ddb..13a0044 100644 --- a/src/options.rs +++ b/src/options.rs @@ -86,6 +86,12 @@ pub struct Args { #[arg(long, default_value = "", help = "Asset hash for windows client.")] pub windows_asset_hash: String, + #[arg(long, default_value = "", help = "Asset hash for linux client.")] + pub linux_asset_hash: String, + + #[arg(long, default_value = "", help = "Asset hash for macOS client.")] + pub mac_asset_hash: String, + #[arg(long, default_value = "", help = "Path to image assets.")] pub image_asset_path: String, diff --git a/src/router/custom_3dmv.rs b/src/router/custom_3dmv.rs index 21e1d44..8379616 100644 --- a/src/router/custom_3dmv.rs +++ b/src/router/custom_3dmv.rs @@ -660,6 +660,9 @@ pub fn purge_song(music_id: i64) { // exists (browse renders an uploader name for every row). Same steps as the // owner's own delete, blob GC included pub fn purge_owner(uid: i64) { + if uid <= 0 { + return; + } if disabled() { return; } @@ -715,6 +718,12 @@ pub fn sweep_blobs() { } } + // Nothing referenced means the catalog is empty (or gone): a sweep would then delete + // every blob on disk, so it is skipped until the catalog has rows again + if referenced.is_empty() { + println!("Custom 3DMV blob sweep: catalog empty, skipped"); + return; + } // No directory means nothing was ever uploaded let Ok(entries) = fs::read_dir(get_data_path("custom_3dmv/blobs")) else { return; diff --git a/src/router/custom_card.rs b/src/router/custom_card.rs index 8d1b424..0980274 100644 --- a/src/router/custom_card.rs +++ b/src/router/custom_card.rs @@ -1545,6 +1545,9 @@ pub fn delete_character(uid: i64, master_character_id: i64) -> Result<(), String // serving a dangling master_character_id, which the client throws on. Those stay, // ownerless, which is the same trade delete_character already makes pub fn purge_owner(uid: i64) { + if uid <= 0 { + return; + } if disabled() { return; } diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index c841d1a..d0e7127 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -243,6 +243,12 @@ pub fn sweep_audio() { } } + // Nothing referenced means the catalog is empty (or gone): a sweep would then delete + // every ogg on disk, so it is skipped until the catalog has rows again + if referenced.is_empty() { + println!("Custom song audio sweep: catalog empty, skipped"); + return; + } // No directory means nothing was ever uploaded let Ok(entries) = fs::read_dir(get_data_path("custom_songs/audio")) else { return; @@ -1334,6 +1340,9 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse { // the owner's own delete does, including the cross-feature MV cascade and the // content-addressed audio GC pub fn purge_owner(uid: i64) { + if uid <= 0 { + return; + } if disabled() { return; } @@ -2724,6 +2733,123 @@ mod tests { assert!(crate::database::custom_card::get_character(character_id).is_none(), "the character survived the purge"); } + // A purge is scoped to ONE owner: nothing another account uploaded, nor an + // ogg two accounts share, may go with it - and a degenerate uid, a broken + // catalog or a disabled feature is a no-op, never a wildcard + #[test] + fn purging_one_account_leaves_other_accounts_uploads_alone() { + let _lock = crate::runtime::lock_test_data_path(); + use crate::database::custom_card as card_db; + use crate::database::custom_3dmv as mv_db; + + let victim = userdata::get_acc("custom-content-purge-victim")["user"]["id"].as_i64().unwrap(); + let bystander = userdata::get_acc("custom-content-purge-bystander")["user"]["id"].as_i64().unwrap(); + assert_ne!(victim, bystander); + for uid in [victim, bystander] { + purge_owner(uid); + crate::router::custom_card::purge_owner(uid); + crate::router::custom_3dmv::purge_owner(uid); + } + + // One song each, plus one song each with byte-identical audio so the two + // owners share a single content-addressed ogg + let v_song = create_song(victim, &song_fields("Purge Victim", 401.0)).unwrap(); + let b_song = create_song(bystander, &song_fields("Purge Bystander", 409.0)).unwrap(); + let v_shared = create_song(victim, &song_fields("Purge Victim Shared", 419.0)).unwrap(); + let b_shared = create_song(bystander, &song_fields("Purge Bystander Shared", 419.0)).unwrap(); + let b_play = database::get_song(b_song).unwrap()["sound"]["play"]["md5"].to_string(); + let shared_play = database::get_song(b_shared).unwrap()["sound"]["play"]["md5"].to_string(); + assert_eq!(database::get_song(v_shared).unwrap()["sound"]["play"]["md5"].to_string(), shared_play); + + // Cards and characters criss-crossed between the two owners + let b_char = card_db::next_character_id(); + card_db::insert_character(b_char, bystander, &object!{ "master_character_id": b_char, "name": "B" }).unwrap(); + let b_card = card_db::next_card_id(); + card_db::insert_card(b_card, b_char, bystander, &object!{ "master_card_id": b_card, "rarity": 1 }, true, true).unwrap(); + let v_card_on_b_char = card_db::next_card_id(); + card_db::insert_card(v_card_on_b_char, b_char, victim, &object!{ "master_card_id": v_card_on_b_char, "rarity": 1 }, true, true).unwrap(); + let v_char = card_db::next_character_id(); + card_db::insert_character(v_char, victim, &object!{ "master_character_id": v_char, "name": "V" }).unwrap(); + let b_card_on_v_char = card_db::next_card_id(); + card_db::insert_card(b_card_on_v_char, v_char, bystander, &object!{ "master_card_id": b_card_on_v_char, "rarity": 1 }, true, true).unwrap(); + let b_mv = mv_db::next_mv_id(); + mv_db::insert_mv(b_mv, b_song, bystander, &object!{ "mv_id": b_mv, "music_id": b_song, "files": [] }, true).unwrap(); + + userdata::delete_account(victim); + + assert!(database::get_song(v_song).is_none(), "the victim's song survived"); + assert!(database::get_song(v_shared).is_none(), "the victim's shared-audio song survived"); + assert!(card_db::get_card(v_card_on_b_char).is_none(), "the victim's card survived"); + + assert!(database::get_song(b_song).is_some(), "the bystander's song was deleted"); + assert!(database::get_song(b_shared).is_some(), "the bystander's shared-audio song was deleted"); + assert!(fs::metadata(get_data_path(&format!("custom_songs/{}", b_song))).is_ok(), "the bystander's song directory was deleted"); + assert!(fs::read(audio_file_path(&b_play)).is_ok(), "the bystander's ogg was unlinked"); + assert!(fs::read(audio_file_path(&shared_play)).is_ok(), "the cross-owner shared ogg was unlinked"); + assert!(card_db::get_card(b_card).is_some(), "the bystander's card was deleted"); + assert!(card_db::get_character(b_char).is_some(), "the bystander's character was deleted"); + assert!(card_db::get_card(b_card_on_v_char).is_some(), "the bystander's card on the victim's character was deleted"); + assert!(card_db::get_character(v_char).is_some(), "a character another account's card still uses was deleted"); + assert!(mv_db::get_mv(b_mv).is_some(), "the bystander's MV was deleted"); + + purge_owner(0); + crate::router::custom_card::purge_owner(0); + crate::router::custom_3dmv::purge_owner(0); + purge_owner(-1); + userdata::delete_account(0); + with_songs_table_broken(|| purge_owner(bystander)); + crate::runtime::set_enable_custom_songs(false); + crate::runtime::set_enable_custom_cards(false); + crate::runtime::set_enable_custom_3dmv(false); + purge_owner(bystander); + crate::router::custom_card::purge_owner(bystander); + crate::router::custom_3dmv::purge_owner(bystander); + crate::runtime::set_enable_custom_songs(true); + crate::runtime::set_enable_custom_cards(true); + crate::runtime::set_enable_custom_3dmv(true); + assert!(database::get_song(b_song).is_some(), "a degenerate, broken-catalog or feature-off purge deleted a song"); + assert!(fs::read(audio_file_path(&b_play)).is_ok(), "a degenerate, broken-catalog or feature-off purge unlinked a live ogg"); + assert!(card_db::get_card(b_card).is_some(), "a degenerate or feature-off purge deleted a card"); + assert!(card_db::get_character(b_char).is_some(), "a degenerate or feature-off purge deleted a character"); + assert!(mv_db::get_mv(b_mv).is_some(), "a degenerate or feature-off purge deleted an MV"); + + // Leave nothing behind: other tests assume an empty obtainable-card pool + purge_owner(bystander); + crate::router::custom_card::purge_owner(bystander); + crate::router::custom_3dmv::purge_owner(bystander); + } + + // The pruning get_acc runs on every login must read an unreadable or blank + // catalog as "unknown", never as "every custom song is dead", and the startup + // sweep must never unlink files when the catalog references nothing + #[test] + fn an_unreadable_or_blank_catalog_never_prunes_or_sweeps() { + let _lock = crate::runtime::lock_test_data_path(); + let uid = userdata::get_acc("custom-content-blank-catalog")["user"]["id"].as_i64().unwrap(); + purge_owner(uid); + let music_id = create_song(uid, &song_fields("Blank Catalog", 383.0)).unwrap(); + let play = database::get_song(music_id).unwrap()["sound"]["play"]["md5"].to_string(); + let live_ogg = audio_file_path(&play); + + let unreadable = with_songs_table_broken(|| database::dead_music_ids(&array![music_id])); + assert!(unreadable.is_none(), "an unreadable catalog reported dead songs"); + with_songs_table_broken(|| sweep_audio()); + assert!(fs::read(&live_ogg).is_ok(), "a sweep over an unreadable catalog unlinked a live ogg"); + + let conn = rusqlite::Connection::open(database::test_db_path()).unwrap(); + conn.execute("ALTER TABLE songs RENAME TO songs_parked", ()).unwrap(); + conn.execute("CREATE TABLE songs (music_id BIGINT NOT NULL PRIMARY KEY, owner_id BIGINT NOT NULL, song TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', downloads_disabled INT NOT NULL DEFAULT 0)", ()).unwrap(); + let blank = database::dead_music_ids(&array![music_id]); + sweep_audio(); + let survived = fs::read(&live_ogg).is_ok(); + conn.execute("DROP TABLE songs", ()).unwrap(); + conn.execute("ALTER TABLE songs_parked RENAME TO songs", ()).unwrap(); + assert!(blank.is_none(), "a blank catalog reported every song dead"); + assert!(survived, "a sweep over a blank catalog unlinked a live ogg"); + assert_eq!(database::dead_music_ids(&array![music_id]).unwrap().len(), 0); + purge_owner(uid); + } + // D1/D6/D7: the whole upload route, end to end - a real multipart body, a real // session, the expansion of a real package, and the create running on the diff --git a/src/router/global.rs b/src/router/global.rs index 0b24890..5c6ce1c 100644 --- a/src/router/global.rs +++ b/src/router/global.rs @@ -49,6 +49,10 @@ static ASSET_VERSIONS: &[AssetVersion] = &[ 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 }, + // Linux / macOS players (StandaloneLinux64 / StandaloneOSX lanes): no baked hash, the + // running build's hash is supplied with --linux-asset-hash / --mac-asset-hash + AssetVersion { region: "JP", platform: "Linux", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "", latest: true }, + AssetVersion { region: "JP", platform: "Mac", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "", latest: true }, //AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true }, ]; @@ -68,6 +72,8 @@ impl AssetVersion { let ov = args.asset_version.as_str(); let oh = match (self.region, self.platform) { ("JP", "Windows") => args.windows_asset_hash.as_str(), + ("JP", "Linux") => args.linux_asset_hash.as_str(), + ("JP", "Mac") => args.mac_asset_hash.as_str(), ("JP", "Android") => args.jp_android_asset_hash.as_str(), ("JP", "iOS") => args.jp_ios_asset_hash.as_str(), ("GL", "Android") => args.en_android_asset_hash.as_str(), @@ -89,7 +95,7 @@ fn valid_hashes(asset_version: &str, platform: &str) -> Vec { if entry.platform != platform { continue; } - if entry.version == asset_version { + if entry.version == asset_version && !entry.hash.is_empty() { out.push(entry.stock_hash()); } if entry.latest { @@ -116,7 +122,7 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option { } } } - if entry.version == asset_version && stock.is_none() { + if entry.version == asset_version && stock.is_none() && !entry.hash.is_empty() { stock = Some(entry.stock_hash()); } } @@ -169,6 +175,8 @@ pub fn parse_platform(header: &str) -> &str { "iphone" => "iOS", "windows" => "Windows", "windowsplayer" => "Windows", + "linuxplayer" => "Linux", + "osxplayer" => "Mac", "webglplayer" => "WebGL", "editor" => "Editor", "windowseditor" => "Editor", @@ -471,3 +479,27 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue { } rv } + +#[cfg(test)] +mod platform_tests { + use super::*; + + #[test] + fn standalone_player_platforms_are_known() { + assert_eq!(parse_platform("WindowsPlayer"), "Windows"); + assert_eq!(parse_platform("LinuxPlayer"), "Linux"); + assert_eq!(parse_platform("OSXPlayer"), "Mac"); + assert_eq!(parse_platform("OSXEditor"), "Editor"); + } + + // Linux / macOS have no baked hash: without the CLI override the platform has no valid + // hash at all (never an empty string presented as one) + #[test] + fn unhashed_platforms_answer_nothing_without_an_override() { + let latest = ASSET_VERSIONS.iter().find(|e| e.latest && e.platform == "Windows").unwrap().version; + assert_eq!(preferred_hash(latest, "Mac"), None); + assert_eq!(preferred_hash(latest, "Linux"), None); + assert!(valid_hashes(latest, "Mac").is_empty()); + assert!(preferred_hash(latest, "Windows").is_some()); + } +} diff --git a/src/router/userdata/mod.rs b/src/router/userdata/mod.rs index c79a865..e219bb1 100644 --- a/src/router/userdata/mod.rs +++ b/src/router/userdata/mod.rs @@ -268,7 +268,9 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool { if candidates.is_empty() { return false; } - let dead = custom_song::dead_music_ids(&candidates); + let Some(dead) = custom_song::dead_music_ids(&candidates) else { + return false; + }; if dead.is_empty() { return false; } @@ -301,7 +303,9 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool { if candidates.is_empty() { return false; } - let dead = custom_card::dead_card_ids(&candidates); + let Some(dead) = custom_card::dead_card_ids(&candidates) else { + return false; + }; if dead.is_empty() { return false; } diff --git a/src/static_handlers.rs b/src/static_handlers.rs index d74321f..58903d3 100644 --- a/src/static_handlers.rs +++ b/src/static_handlers.rs @@ -71,7 +71,7 @@ fn platform_guard(ctx: &guard::GuardContext) -> bool { .split('/') .nth(1) .unwrap_or(""); - matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "WebGL" | "iOS") + matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "StandaloneOSX" | "WebGL" | "iOS") } pub fn routes(cfg: &mut web::ServiceConfig) {