Add macos route and some other tests I guess

This commit is contained in:
Ethan O'Brien
2026-08-28 21:40:26 -05:00
parent 2ceff8ffc6
commit 971aa32361
9 changed files with 235 additions and 14 deletions
+37 -5
View File
@@ -350,7 +350,10 @@ pub fn get_browse_cards() -> JsonValue {
// considered, and ids are never reused, so official (or imported) cards can't // 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 // 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 // still has its row - only genuinely deleted ids are returned
pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue { // None = the catalog could not be read (or is entirely empty while players still hold
// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly
// like one where every id is dead, and get_acc saves the pruned userdata.
pub fn dead_card_ids(candidates: &JsonValue) -> Option<JsonValue> {
let mut ids: Vec<i64> = Vec::new(); let mut ids: Vec<i64> = Vec::new();
for id in candidates.members() { for id in candidates.members() {
let Some(id) = id.as_i64() else { continue; }; let Some(id) = id.as_i64() else { continue; };
@@ -359,17 +362,23 @@ pub fn dead_card_ids(candidates: &JsonValue) -> JsonValue {
} }
} }
if ids.is_empty() { if ids.is_empty() {
return array![]; return Some(array![]);
} }
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).unwrap_or(array![]); let alive = DATABASE.lock_and_select_all(&format!("SELECT master_card_id FROM cards WHERE master_card_id IN ({})", list), params!()).ok()?;
if alive.is_empty() {
let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM cards", params!()).ok()?;
if total == 0 {
return None;
}
}
let mut rv = array![]; let mut rv = array![];
for id in ids { for id in ids {
if !alive.contains(id) { if !alive.contains(id) {
rv.push(id).unwrap(); rv.push(id).unwrap();
} }
} }
rv Some(rv)
} }
// The published + obtainable pool the custom gacha banner draws from, per // 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(); insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false).unwrap();
delete_card(dead); 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.len(), 1);
assert_eq!(dead_ids[0].as_i64(), Some(dead)); assert_eq!(dead_ids[0].as_i64(), Some(dead));
wipe(3008); 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);
}
} }
+13 -4
View File
@@ -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 // 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 // that's merely private/shared still has its row - only genuinely deleted ids
// (which are never reused) are returned // (which are never reused) are returned
pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue { // None = the catalog could not be read (or is entirely empty while players still hold
// custom ids): the caller must prune NOTHING then. An unreadable catalog looks exactly
// like one where every id is dead, and get_acc saves the pruned userdata.
pub fn dead_music_ids(candidates: &JsonValue) -> Option<JsonValue> {
let mut ids: Vec<i64> = Vec::new(); let mut ids: Vec<i64> = Vec::new();
for id in candidates.members() { for id in candidates.members() {
let Some(id) = id.as_i64() else { continue; }; let Some(id) = id.as_i64() else { continue; };
@@ -374,17 +377,23 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue {
} }
} }
if ids.is_empty() { if ids.is_empty() {
return array![]; return Some(array![]);
} }
let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(","); let list = ids.iter().map(|id| id.to_string()).collect::<Vec<_>>().join(",");
let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).unwrap_or(array![]); let alive = DATABASE.lock_and_select_all(&format!("SELECT music_id FROM songs WHERE music_id IN ({})", list), params!()).ok()?;
if alive.is_empty() {
let total: i64 = DATABASE.lock_and_select_type("SELECT COUNT(*) FROM songs", params!()).ok()?;
if total == 0 {
return None;
}
}
let mut rv = array![]; let mut rv = array![];
for id in ids { for id in ids {
if !alive.contains(id) { if !alive.contains(id) {
rv.push(id).unwrap(); rv.push(id).unwrap();
} }
} }
rv Some(rv)
} }
// Every stored catalog blob, unparsed and unfiltered by visibility. Only the // Every stored catalog blob, unparsed and unfiltered by visibility. Only the
+6
View File
@@ -86,6 +86,12 @@ pub struct Args {
#[arg(long, default_value = "", help = "Asset hash for windows client.")] #[arg(long, default_value = "", help = "Asset hash for windows client.")]
pub windows_asset_hash: String, 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.")] #[arg(long, default_value = "", help = "Path to image assets.")]
pub image_asset_path: String, pub image_asset_path: String,
+9
View File
@@ -660,6 +660,9 @@ pub fn purge_song(music_id: i64) {
// exists (browse renders an uploader name for every row). Same steps as the // exists (browse renders an uploader name for every row). Same steps as the
// owner's own delete, blob GC included // owner's own delete, blob GC included
pub fn purge_owner(uid: i64) { pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() { if disabled() {
return; 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 // No directory means nothing was ever uploaded
let Ok(entries) = fs::read_dir(get_data_path("custom_3dmv/blobs")) else { let Ok(entries) = fs::read_dir(get_data_path("custom_3dmv/blobs")) else {
return; return;
+3
View File
@@ -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, // serving a dangling master_character_id, which the client throws on. Those stay,
// ownerless, which is the same trade delete_character already makes // ownerless, which is the same trade delete_character already makes
pub fn purge_owner(uid: i64) { pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() { if disabled() {
return; return;
} }
+126
View File
@@ -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 // No directory means nothing was ever uploaded
let Ok(entries) = fs::read_dir(get_data_path("custom_songs/audio")) else { let Ok(entries) = fs::read_dir(get_data_path("custom_songs/audio")) else {
return; 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 // the owner's own delete does, including the cross-feature MV cascade and the
// content-addressed audio GC // content-addressed audio GC
pub fn purge_owner(uid: i64) { pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() { if disabled() {
return; return;
} }
@@ -2724,6 +2733,123 @@ mod tests {
assert!(crate::database::custom_card::get_character(character_id).is_none(), "the character survived the purge"); 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 // 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 // session, the expansion of a real package, and the create running on the
+34 -2
View File
@@ -49,6 +49,10 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
AssetVersion { region: "JP", platform: "Windows", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "b8d0e7edcb63f5bdd28817a597772ca6", latest: true }, 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: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", 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 }, //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 ov = args.asset_version.as_str();
let oh = match (self.region, self.platform) { let oh = match (self.region, self.platform) {
("JP", "Windows") => args.windows_asset_hash.as_str(), ("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", "Android") => args.jp_android_asset_hash.as_str(),
("JP", "iOS") => args.jp_ios_asset_hash.as_str(), ("JP", "iOS") => args.jp_ios_asset_hash.as_str(),
("GL", "Android") => args.en_android_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<String> {
if entry.platform != platform { if entry.platform != platform {
continue; continue;
} }
if entry.version == asset_version { if entry.version == asset_version && !entry.hash.is_empty() {
out.push(entry.stock_hash()); out.push(entry.stock_hash());
} }
if entry.latest { if entry.latest {
@@ -116,7 +122,7 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option<String> {
} }
} }
} }
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()); stock = Some(entry.stock_hash());
} }
} }
@@ -169,6 +175,8 @@ pub fn parse_platform(header: &str) -> &str {
"iphone" => "iOS", "iphone" => "iOS",
"windows" => "Windows", "windows" => "Windows",
"windowsplayer" => "Windows", "windowsplayer" => "Windows",
"linuxplayer" => "Linux",
"osxplayer" => "Mac",
"webglplayer" => "WebGL", "webglplayer" => "WebGL",
"editor" => "Editor", "editor" => "Editor",
"windowseditor" => "Editor", "windowseditor" => "Editor",
@@ -471,3 +479,27 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue {
} }
rv 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());
}
}
+6 -2
View File
@@ -268,7 +268,9 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
if candidates.is_empty() { if candidates.is_empty() {
return false; 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() { if dead.is_empty() {
return false; return false;
} }
@@ -301,7 +303,9 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
if candidates.is_empty() { if candidates.is_empty() {
return false; 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() { if dead.is_empty() {
return false; return false;
} }
+1 -1
View File
@@ -71,7 +71,7 @@ fn platform_guard(ctx: &guard::GuardContext) -> bool {
.split('/') .split('/')
.nth(1) .nth(1)
.unwrap_or(""); .unwrap_or("");
matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "WebGL" | "iOS") matches!(platform, "Android" | "StandaloneWindows64" | "StandaloneLinux64" | "StandaloneOSX" | "WebGL" | "iOS")
} }
pub fn routes(cfg: &mut web::ServiceConfig) { pub fn routes(cfg: &mut web::ServiceConfig) {