diff --git a/src/database.rs b/src/database.rs index b3ea254..d6d0fa2 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,2 +1,4 @@ pub mod gree; pub mod custom_song; +pub mod custom_card; +pub mod permissions; diff --git a/src/database/custom_card.rs b/src/database/custom_card.rs new file mode 100644 index 0000000..da59990 --- /dev/null +++ b/src/database/custom_card.rs @@ -0,0 +1,540 @@ +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_cards.db", setup_tables); +} + +// master_card_id == illust_prefix * 10000 + seq, and illust_id is derived from +// the same pair as {prefix:05}_{seq:04}_{00|01}. Official cards are 8-digit +// (prefix 1001-4014), the baked SIF1 import owns prefixes 10000-14999 (rows in +// client masterdata), so runtime uploads start at prefix 15000. seq 0 never +// exists - official illust ids start at 0001 - so an id landing on a prefix +// boundary is skipped, not issued +pub const FIRST_ILLUST_PREFIX: i64 = 15000; +pub const FIRST_CARD_ID: i64 = FIRST_ILLUST_PREFIX * 10000 + 1; +pub const LAST_CARD_ID: i64 = 999_999_999; + +// Official characters are 1001-4014, the SIF1 import took 5001-5172 and +// 6001-6009 - client masterdata tops out at 6009, so runtime characters start +// at 7001. The ceiling keeps the id 5-digit (sign_{0:D5} asset naming) and +// below the 99999 sentinel SDCharacter substitutes for OTHER-category +// characters +pub const FIRST_CHARACTER_ID: i64 = 7001; +pub const LAST_CHARACTER_ID: i64 = 99_998; + +// Cards and characters are one JSON blob each, in the exact shape +// /api/custom_card/list serves - except `published`/`obtainable`/`rarity`, +// which live in their own columns (the draw pool and the catalog filter query +// them) and are injected into the served object where the wire wants them +fn setup_tables(conn: &rusqlite::Connection) { + conn.execute_batch(" +CREATE TABLE IF NOT EXISTS 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 +); +CREATE TABLE IF NOT EXISTS characters ( + master_character_id BIGINT NOT NULL PRIMARY KEY, + owner_id BIGINT NOT NULL, + character TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS revision ( + id INT NOT NULL PRIMARY KEY, + revision BIGINT NOT NULL, + last_card_id BIGINT NOT NULL, + last_character_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::().unwrap_or(0) +} + +// Bumped on every create/update/delete/publish/obtainable 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_card_id, last_character_id) VALUES (1, 1, 0, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!()); +} + +// Ids are never reused after a delete: a client that cached a dead id can't +// confuse it with a later upload, and a player's stored card row for a deleted +// card can't silently become a different card. last_card_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_card_id() -> i64 { + let issued = DATABASE.lock_and_select("SELECT last_card_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::().unwrap_or(0); + let max = DATABASE.lock_and_select("SELECT MAX(master_card_id) FROM cards", params!()).unwrap_or_default().parse::().unwrap_or(0); + let mut rv = std::cmp::max(std::cmp::max(issued, max), FIRST_CARD_ID - 1) + 1; + // seq 0 doesn't exist in the illust naming scheme + if rv % 10000 == 0 { + rv += 1; + } + rv +} + +pub fn next_character_id() -> i64 { + let issued = DATABASE.lock_and_select("SELECT last_character_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::().unwrap_or(0); + let max = DATABASE.lock_and_select("SELECT MAX(master_character_id) FROM characters", params!()).unwrap_or_default().parse::().unwrap_or(0); + std::cmp::max(std::cmp::max(issued, max), FIRST_CHARACTER_ID - 1) + 1 +} + +pub fn insert_card(master_card_id: i64, master_character_id: i64, owner_id: i64, card: &JsonValue, published: bool, obtainable: bool) { + DATABASE.lock_and_exec( + "INSERT INTO cards (master_card_id, master_character_id, owner_id, card, rarity, published, obtainable) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params!(master_card_id, master_character_id, owner_id, jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), published as i64, obtainable as i64) + ); + DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, ?1, 0) ON CONFLICT(id) DO UPDATE SET last_card_id=?1", params!(master_card_id)); +} + +pub fn insert_character(master_character_id: i64, owner_id: i64, character: &JsonValue) { + DATABASE.lock_and_exec( + "INSERT INTO characters (master_character_id, owner_id, character) VALUES (?1, ?2, ?3)", + params!(master_character_id, owner_id, jzon::stringify(character.clone())) + ); + DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_card_id, last_character_id) VALUES (1, 0, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_character_id=?1", params!(master_character_id)); +} + +// The catalog blob only. The owner and the published/obtainable flags live in +// their own columns and are untouched here; rarity tracks the blob +pub fn update_card(master_card_id: i64, card: &JsonValue) { + DATABASE.lock_and_exec("UPDATE cards SET card=?1, rarity=?2 WHERE master_card_id=?3", params!(jzon::stringify(card.clone()), card["rarity"].as_i64().unwrap_or(1), master_card_id)); +} + +pub fn update_character(master_character_id: i64, character: &JsonValue) { + DATABASE.lock_and_exec("UPDATE characters SET character=?1 WHERE master_character_id=?2", params!(jzon::stringify(character.clone()), master_character_id)); +} + +pub fn delete_card(master_card_id: i64) { + DATABASE.lock_and_exec("DELETE FROM cards WHERE master_card_id=?1", params!(master_card_id)); +} + +pub fn delete_character(master_character_id: i64) { + DATABASE.lock_and_exec("DELETE FROM characters WHERE master_character_id=?1", params!(master_character_id)); +} + +// The stored blob with the column-backed wire field injected +fn card_with_flags(mut card: JsonValue) -> JsonValue { + let id = card["master_card_id"].as_i64().unwrap_or(0); + card["obtainable"] = is_obtainable(id).into(); + card +} + +pub fn get_card(master_card_id: i64) -> Option { + let card = DATABASE.lock_and_select("SELECT card FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?; + Some(card_with_flags(jzon::parse(&card).ok()?)) +} + +pub fn get_character(master_character_id: i64) -> Option { + let character = DATABASE.lock_and_select("SELECT character FROM characters WHERE master_character_id=?1", params!(master_character_id)).ok()?; + jzon::parse(&character).ok() +} + +pub fn get_card_owner(master_card_id: i64) -> Option { + DATABASE.lock_and_select("SELECT owner_id FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?.parse::().ok() +} + +pub fn get_character_owner(master_character_id: i64) -> Option { + DATABASE.lock_and_select("SELECT owner_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).ok()?.parse::().ok() +} + +// The character a runtime card belongs to, straight out of its own column. +// guest::proxy_card_id resolves an unviewable card through this, so it stays a +// plain lookup and never a decode of the blob +pub fn character_of(master_card_id: i64) -> Option { + DATABASE.lock_and_select("SELECT master_character_id FROM cards WHERE master_card_id=?1", params!(master_card_id)).ok()?.parse::().ok() +} + +pub fn is_published(master_card_id: i64) -> bool { + DATABASE.lock_and_select("SELECT published FROM cards WHERE master_card_id=?1", params!(master_card_id)).unwrap_or_default() == "1" +} + +pub fn set_published(master_card_id: i64, published: bool) { + DATABASE.lock_and_exec("UPDATE cards SET published=?1 WHERE master_card_id=?2", params!(published as i64, master_card_id)); +} + +pub fn is_obtainable(master_card_id: i64) -> bool { + DATABASE.lock_and_select("SELECT obtainable FROM cards WHERE master_card_id=?1", params!(master_card_id)).unwrap_or_default() == "1" +} + +pub fn set_obtainable(master_card_id: i64, obtainable: bool) { + DATABASE.lock_and_exec("UPDATE cards SET obtainable=?1 WHERE master_card_id=?2", params!(obtainable as i64, master_card_id)); +} + +pub fn has_character(master_character_id: i64) -> bool { + DATABASE.lock_and_select("SELECT master_character_id FROM characters WHERE master_character_id=?1", params!(master_character_id)).is_ok() +} + +pub fn card_count_for_owner(owner_id: i64) -> i64 { + DATABASE.lock_and_select_type::("SELECT COUNT(*) FROM cards WHERE owner_id=?1", params!(owner_id)).unwrap_or(0) +} + +// How many cards still point at this character. A referenced character can't +// be deleted +pub fn cards_using_character(master_character_id: i64) -> i64 { + DATABASE.lock_and_select_type::("SELECT COUNT(*) FROM cards WHERE master_character_id=?1", params!(master_character_id)).unwrap_or(0) +} + +// A custom character is publicly visible when a published card references it - +// that's also what lets another uploader build a card on it +pub fn character_publicly_visible(master_character_id: i64) -> bool { + DATABASE.lock_and_select_type::( + "SELECT COUNT(*) FROM cards WHERE master_character_id=?1 AND published=1", + params!(master_character_id) + ).unwrap_or(0) > 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 card catalog this user is served: every published card, their own +// drafts, and any card their game account already owns (`owned` - so an +// unpublish never leaves a player holding an id their client can't resolve) +pub fn get_cards_for_user(user_id: i64, owned: &[i64]) -> JsonValue { + let rows = parse_blobs(DATABASE.lock_and_select_all( + "SELECT card FROM cards WHERE published=1 OR owner_id=?1 ORDER BY master_card_id", + params!(user_id) + ).unwrap_or(array![])); + let mut rv = array![]; + let mut ids: Vec = Vec::new(); + for card in rows.members() { + ids.push(card["master_card_id"].as_i64().unwrap_or(0)); + rv.push(card_with_flags(card.clone())).unwrap(); + } + for id in owned { + if ids.contains(id) { + continue; + } + if let Some(card) = get_card(*id) { + rv.push(card).unwrap(); + } + } + rv +} + +// The character catalog rides along with the cards: a custom character is +// included exactly when a served card references it, or the requester owns it +// (so a draft character never leaks, and the catalog is referentially closed - +// a served card can never name a master_character_id the same response failed +// to deliver) +pub fn get_characters_for_cards(user_id: i64, cards: &JsonValue) -> JsonValue { + let mut ids: Vec = Vec::new(); + for card in cards.members() { + let id = card["master_character_id"].as_i64().unwrap_or(0); + if (FIRST_CHARACTER_ID..=LAST_CHARACTER_ID).contains(&id) && !ids.contains(&id) { + ids.push(id); + } + } + let own = DATABASE.lock_and_select_all("SELECT master_character_id FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(user_id)).unwrap_or(array![]); + for id in own.members() { + let id = id.as_i64().unwrap_or(0); + if !ids.contains(&id) { + ids.push(id); + } + } + ids.sort(); + let mut rv = array![]; + for id in ids { + if let Some(character) = get_character(id) { + rv.push(character).unwrap(); + } + } + rv +} + +// The custom characters this user may build a card on (mirrors +// validate_character_ref): their own, plus any that is publicly visible +// through a published card. Feeds the webui's character picker +pub fn get_selectable_characters(user_id: i64) -> JsonValue { + parse_blobs(DATABASE.lock_and_select_all(" + SELECT character FROM characters + WHERE owner_id=?1 OR master_character_id IN (SELECT master_character_id FROM cards WHERE published=1) + ORDER BY master_character_id", params!(user_id)).unwrap_or(array![])) +} + +// Card blobs plus the flag columns, for the webui manage view +pub fn get_cards_by_owner(owner_id: i64) -> JsonValue { + let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE owner_id=?1 ORDER BY master_card_id", params!(owner_id)).unwrap_or(array![])); + let mut rv = array![]; + for card in rows.members() { + let mut card = card_with_flags(card.clone()); + card["published"] = is_published(card["master_card_id"].as_i64().unwrap_or(0)).into(); + rv.push(card).unwrap(); + } + rv +} + +pub fn get_characters_by_owner(owner_id: i64) -> JsonValue { + parse_blobs(DATABASE.lock_and_select_all("SELECT character FROM characters WHERE owner_id=?1 ORDER BY master_character_id", params!(owner_id)).unwrap_or(array![])) +} + +// The webui card browser: every published card, plus the owner id so the page +// can label the uploader +pub fn get_browse_cards() -> JsonValue { + let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT card FROM cards WHERE published=1 ORDER BY master_card_id", params!()).unwrap_or(array![])); + let mut rv = array![]; + for card in rows.members() { + let mut card = card_with_flags(card.clone()); + card["owner_id"] = get_card_owner(card["master_card_id"].as_i64().unwrap_or(0)).unwrap_or(0).into(); + rv.push(card).unwrap(); + } + rv +} + +// Which of these candidate ids no longer exist. Only the runtime band is ever +// 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 { + let mut ids: Vec = Vec::new(); + for id in candidates.members() { + let Some(id) = id.as_i64() else { continue; }; + if (FIRST_CARD_ID..=LAST_CARD_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::>().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 mut rv = array![]; + for id in ids { + if !alive.contains(id) { + rv.push(id).unwrap(); + } + } + rv +} + +// The published + obtainable pool the custom gacha banner draws from, per +// rarity +pub fn obtainable_card_ids(rarity: i64) -> Vec { + let rows = DATABASE.lock_and_select_all( + "SELECT master_card_id FROM cards WHERE published=1 AND obtainable=1 AND rarity=?1 ORDER BY master_card_id", + params!(rarity) + ).unwrap_or(array![]); + rows.members().filter_map(|id| id.as_i64()).collect() +} + +// Resolve a content-addressed art md5 to the file under custom_cards/ that +// currently holds those bytes: card art lives at {card_id}/{kind}_{variant}.png, +// character art at characters/{character_id}/{kind}.png. Art is stored per +// entity (not a shared md5 store) and the catalog md5 always tracks the +// on-disk bytes - so this is the index the /custom_card/data/{md5} route +// serves from, and it self-heals: a replaced file gets a new md5 and the old +// one simply stops resolving +pub fn find_asset_by_md5(md5: &str) -> Option { + let like = format!("%{}%", md5); + if let Ok(blob) = DATABASE.lock_and_select("SELECT card FROM cards WHERE card LIKE ?1", params!(like.clone())) { + if let Ok(card) = jzon::parse(&blob) { + let master_card_id = card["master_card_id"].as_i64()?; + for art in card["art"].members() { + if art["md5"].as_str() == Some(md5) { + return Some(format!("{}/{}_{}.png", master_card_id, art["kind"], art["variant"])); + } + } + } + } + let blob = DATABASE.lock_and_select("SELECT character FROM characters WHERE character LIKE ?1", params!(like)).ok()?; + let character = jzon::parse(&blob).ok()?; + let id = character["master_character_id"].as_i64()?; + for art in character["art"].members() { + if art["md5"].as_str() == Some(md5) { + return Some(format!("characters/{}/{}.png", id, art["kind"])); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use jzon::object; + + fn card_blob(id: i64, rarity: i64) -> JsonValue { + object!{ + "master_card_id": id, + "master_character_id": 1001, + "rarity": rarity, + "art": [{ "kind": "c", "variant": "00", "md5": format!("{:032x}", id), "size": 1 }] + } + } + + fn wipe(owner: i64) { + for card in get_cards_by_owner(owner).members() { + delete_card(card["master_card_id"].as_i64().unwrap()); + } + for character in get_characters_by_owner(owner).members() { + delete_character(character["master_character_id"].as_i64().unwrap()); + } + } + + // Ids are sequential from FIRST_CARD_ID, never reused after a delete, and + // never land on a seq-0 prefix boundary + #[test] + fn ids_are_sequential_and_never_reused() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(3001); + + let first = next_card_id(); + assert!(first >= FIRST_CARD_ID); + assert_ne!(first % 10000, 0); + insert_card(first, 1001, 3001, &card_blob(first, 1), false, false); + let second = next_card_id(); + assert_eq!(second, first + 1); + insert_card(second, 1001, 3001, &card_blob(second, 1), false, false); + delete_card(second); + assert_eq!(get_card(second), None); + // The high-water mark survives the delete, so the id is retired + assert_eq!(next_card_id(), second + 1); + + let character = next_character_id(); + assert!(character >= FIRST_CHARACTER_ID); + insert_character(character, 3001, &object!{ "master_character_id": character }); + assert_eq!(next_character_id(), character + 1); + delete_character(character); + assert_eq!(next_character_id(), character + 1); + + wipe(3001); + assert!(next_card_id() > second); + } + + // Published cards go to everyone, drafts only to their owner, a game + // account that owns a card keeps seeing it even unpublished, and a + // character rides along with any card the viewer is served + #[test] + fn catalog_filters_per_user() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(3003); + wipe(3004); + + let character = next_character_id(); + insert_character(character, 3003, &object!{ "master_character_id": character }); + let published = next_card_id(); + let mut blob = card_blob(published, 3); + blob["master_character_id"] = character.into(); + insert_card(published, character, 3003, &blob, true, true); + let draft = next_card_id(); + insert_card(draft, character, 3003, &card_blob(draft, 1), false, false); + + let owner_view = get_cards_for_user(3003, &[]); + assert_eq!(owner_view.len(), 2); + let other_view = get_cards_for_user(3004, &[]); + assert_eq!(other_view.len(), 1); + assert_eq!(other_view[0]["master_card_id"].as_i64(), Some(published)); + // The wire `obtainable` field comes from the column + assert_eq!(other_view[0]["obtainable"].as_bool(), Some(true)); + + // A player who owns the draft in game still resolves it + let holder_view = get_cards_for_user(3004, &[draft]); + assert_eq!(holder_view.len(), 2); + + // Unpublishing removes it from strangers, not from holders + set_published(published, false); + assert!(get_cards_for_user(3004, &[]).is_empty()); + assert_eq!(get_cards_for_user(3004, &[published]).len(), 1); + set_published(published, true); + + // Characters follow the served cards; a stranger with no visible card + // on the character doesn't get it + let characters = get_characters_for_cards(3004, &get_cards_for_user(3004, &[])); + assert_eq!(characters.len(), 1); + assert_eq!(characters[0]["master_character_id"].as_i64(), Some(character)); + set_published(published, false); + assert!(get_characters_for_cards(3004, &get_cards_for_user(3004, &[])).is_empty()); + // The owner always sees their own character + assert_eq!(get_characters_for_cards(3003, &get_cards_for_user(3003, &[])).len(), 1); + set_published(published, true); + + assert_eq!(character_of(published), Some(character)); + assert_eq!(cards_using_character(character), 2); + assert!(character_publicly_visible(character)); + assert_eq!(card_count_for_owner(3003), 2); + + wipe(3003); + wipe(3004); + } + + // The draw pool is exactly the published + obtainable cards of the rarity + #[test] + fn obtainable_pool_by_rarity() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(3005); + + let r1 = next_card_id(); + insert_card(r1, 1001, 3005, &card_blob(r1, 1), true, true); + let r3 = next_card_id(); + insert_card(r3, 1001, 3005, &card_blob(r3, 3), true, true); + let unpublished = next_card_id(); + insert_card(unpublished, 1001, 3005, &card_blob(unpublished, 1), false, true); + let unobtainable = next_card_id(); + insert_card(unobtainable, 1001, 3005, &card_blob(unobtainable, 1), true, false); + + assert_eq!(obtainable_card_ids(1), vec![r1]); + assert_eq!(obtainable_card_ids(3), vec![r3]); + assert!(obtainable_card_ids(2).is_empty()); + set_obtainable(unobtainable, true); + assert_eq!(obtainable_card_ids(1), vec![r1, unobtainable]); + + wipe(3005); + } + + #[test] + fn md5_resolves_to_the_file_holding_the_bytes() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(3007); + + let id = next_card_id(); + insert_card(id, 1001, 3007, &card_blob(id, 1), true, false); + assert_eq!(find_asset_by_md5(&format!("{:032x}", id)), Some(format!("{}/c_00.png", id))); + assert_eq!(find_asset_by_md5("00000000000000000000000000000000"), None); + + let character = next_character_id(); + insert_character(character, 3007, &object!{ + "master_character_id": character, + "art": [{ "kind": "icon", "md5": "aabbccddeeff00112233445566778899", "size": 1 }] + }); + assert_eq!( + find_asset_by_md5("aabbccddeeff00112233445566778899"), + Some(format!("characters/{}/icon.png", character)) + ); + + wipe(3007); + } + + // Deleted ids come back from dead_card_ids; unpublished, imported and + // official ids never do + #[test] + fn dead_ids_are_deleted_ids_only() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(3008); + + let alive = next_card_id(); + insert_card(alive, 1001, 3008, &card_blob(alive, 1), false, false); + let dead = next_card_id(); + insert_card(dead, 1001, 3008, &card_blob(dead, 1), true, false); + delete_card(dead); + + let dead_ids = dead_card_ids(&array![alive, dead, 10010001, 100010001, dead]); + assert_eq!(dead_ids.len(), 1); + assert_eq!(dead_ids[0].as_i64(), Some(dead)); + + wipe(3008); + } +} diff --git a/src/database/permissions.rs b/src/database/permissions.rs new file mode 100644 index 0000000..0a1491a --- /dev/null +++ b/src/database/permissions.rs @@ -0,0 +1,353 @@ +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("permissions.db", setup_tables); +} + +// Scopes are flat dotted strings and imply everything below them: holding +// "card" grants "card.upload", and "*" grants everything. That hierarchy is +// the only notion of "level" - there is no rank integer, so a new capability +// is one line here and never a renumbering of anything else. +// +// Every call site must pass one of these consts, never a literal: an +// unrecognised scope string can only ever fail closed in has(), but grant() +// rejects it outright so a typo can't be persisted either. +pub const ALL: &str = "*"; + +pub const CARD: &str = "card"; +// Create custom cards/characters, and edit or delete your OWN uploads +pub const CARD_UPLOAD: &str = "card.upload"; +// Publish/unpublish and mark obtainable, on your OWN uploads +pub const CARD_PUBLISH: &str = "card.publish"; +// Moderation: edit, delete, publish or unpublish ANYBODY's cards +pub const CARD_EDIT: &str = "card.edit"; + +pub const PERMISSION: &str = "permission"; +pub const PERMISSION_GRANT: &str = "permission.grant"; +pub const PERMISSION_REVOKE: &str = "permission.revoke"; + +// The whole grantable vocabulary, subtree roots included. Anything not in here +// cannot be written to the table +pub const SCOPES: &[&str] = &[ + ALL, + CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, + PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE +]; + +// Grants live in their own database rather than in userdata.db so that an +// account purge can never take administrative state with it +fn setup_tables(conn: &rusqlite::Connection) { + conn.execute_batch(" +CREATE TABLE IF NOT EXISTS grants ( + user_id BIGINT NOT NULL, + scope TEXT NOT NULL, + granted_by BIGINT NOT NULL, + granted_at BIGINT NOT NULL, + PRIMARY KEY (user_id, scope) +); + ").unwrap(); +} + +// The owners are a process-level flag (--owner) rather than table rows: they +// are the bootstrap grantors, so they have to work on a fresh install with an +// empty (or hand-deleted) permissions.db, and they must not be revocable +// through the webui +fn is_owner(user_id: i64) -> bool { + user_id > 0 && crate::runtime::get_owners().contains(&user_id) +} + +// Every scope that would satisfy a request for `scope`: "*", each dotted +// ancestor, and the scope itself. Matching is on whole dot-separated segments, +// so "car" never satisfies "card.upload" +fn implied_by(scope: &str) -> Vec { + if scope == ALL { + return vec![String::from(ALL)]; + } + let mut rv = vec![String::from(ALL)]; + let mut prefix = String::new(); + for part in scope.split('.') { + if !prefix.is_empty() { + prefix.push('.'); + } + prefix.push_str(part); + rv.push(prefix.clone()); + } + rv +} + +fn held_scopes(user_id: i64) -> Vec { + 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() +} + +fn insert(user_id: i64, scope: &str, granted_by: i64) { + DATABASE.lock_and_exec( + "INSERT OR IGNORE INTO grants (user_id, scope, granted_by, granted_at) VALUES (?1, ?2, ?3, ?4)", + params!(user_id, scope, granted_by, global::timestamp() as i64) + ); +} + +// A user with no row holds nothing at all +pub fn has(user_id: i64, scope: &str) -> bool { + if user_id <= 0 { + return false; + } + if is_owner(user_id) { + return true; + } + let held = held_scopes(user_id); + implied_by(scope).iter().any(|candidate| held.contains(candidate)) +} + +// Everything this user holds, for the webui to hide what it can't use. An +// owner's implicit "*" is reported here even though it has no row +pub fn scopes_for(user_id: i64) -> JsonValue { + if user_id <= 0 { + return array![]; + } + let mut scopes: Vec = Vec::new(); + if is_owner(user_id) { + scopes.push(String::from(ALL)); + } + for scope in held_scopes(user_id) { + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + let mut rv = array![]; + for scope in scopes { + rv.push(scope).unwrap(); + } + rv +} + +pub fn grants() -> JsonValue { + let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap(); + let Ok(mut stmt) = conn.prepare("SELECT user_id, scope, granted_by, granted_at FROM grants ORDER BY user_id, scope") else { + return array![]; + }; + let Ok(mapped) = stmt.query_map(params!(), |row| { + Ok(object!{ + user_id: row.get::(0)?, + scope: row.get::(1)?, + granted_by: row.get::(2)?, + granted_at: row.get::(3)? + }) + }) else { + return array![]; + }; + let mut rv = array![]; + for row in mapped.flatten() { + rv.push(row).unwrap(); + } + rv +} + +// The only way a row is ever written. Two conditions, both required: +// +// 1. the grantor holds permission.grant, and +// 2. the grantor holds the scope being granted +// +// (2) is what makes escalation impossible. has() only ever implies downwards, +// so holding a leaf never satisfies its parent - a user with card.upload can +// hand out card.upload and nothing else, and can no more grant themselves +// "card" (or "*") than they can grant it to anybody else. Both checks read the +// live table, so a revoked grantor loses the ability on their next request +pub fn grant(user_id: i64, scope: &str, granted_by: i64) -> Result<(), String> { + if user_id <= 0 { + return Err(String::from("Invalid user id")); + } + if !SCOPES.contains(&scope) { + return Err(format!("Unknown scope '{}'", scope)); + } + if !has(granted_by, PERMISSION_GRANT) { + return Err(String::from("You do not have permission to grant scopes")); + } + if !has(granted_by, scope) { + return Err(format!("You cannot grant '{}' because you do not hold it yourself", scope)); + } + insert(user_id, scope, granted_by); + Ok(()) +} + +// Revoking needs the same "you must hold it yourself" rule as granting, so a +// junior admin can't strip a senior one. An owner has no rows to delete, but +// the check is explicit so it stays true if that ever changes +pub fn revoke(user_id: i64, scope: &str, revoked_by: i64) -> Result<(), String> { + if user_id <= 0 { + return Err(String::from("Invalid user id")); + } + if !SCOPES.contains(&scope) { + return Err(format!("Unknown scope '{}'", scope)); + } + if !has(revoked_by, PERMISSION_REVOKE) { + return Err(String::from("You do not have permission to revoke scopes")); + } + if !has(revoked_by, scope) { + return Err(format!("You cannot revoke '{}' because you do not hold it yourself", scope)); + } + if is_owner(user_id) { + return Err(String::from("A server owner's scopes cannot be revoked")); + } + DATABASE.lock_and_exec("DELETE FROM grants WHERE user_id=?1 AND scope=?2", params!(user_id, scope)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Every test holds the shared test data path lock, so uids are hand-picked + // to not collide between tests rather than cleaned up between them + fn wipe(user_id: i64) { + DATABASE.lock_and_exec("DELETE FROM grants WHERE user_id=?1", params!(user_id)); + } + + #[test] + fn subtree_implies_leaf_but_not_the_reverse() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(101); + assert!(!has(101, CARD_UPLOAD)); + insert(101, CARD, 0); + assert!(has(101, CARD_UPLOAD)); + assert!(has(101, CARD_EDIT)); + assert!(has(101, CARD)); + assert!(!has(101, PERMISSION_GRANT)); + assert!(!has(101, ALL)); + wipe(101); + + insert(101, CARD_UPLOAD, 0); + assert!(has(101, CARD_UPLOAD)); + assert!(!has(101, CARD)); + assert!(!has(101, CARD_PUBLISH)); + wipe(101); + } + + #[test] + fn star_implies_everything() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(103); + insert(103, ALL, 0); + for scope in SCOPES { + assert!(has(103, scope), "scope {}", scope); + } + wipe(103); + } + + #[test] + fn partial_segment_is_not_a_prefix() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(104); + insert(104, "car", 0); + assert!(!has(104, CARD_UPLOAD)); + assert!(!has(104, CARD)); + insert(104, "card.uplo", 0); + assert!(!has(104, CARD_UPLOAD)); + wipe(104); + } + + #[test] + fn absent_user_holds_nothing() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(105); + for scope in SCOPES { + assert!(!has(105, scope), "scope {}", scope); + } + assert!(scopes_for(105).is_empty()); + assert!(!has(0, ALL)); + assert!(!has(-1, ALL)); + wipe(105); + } + + #[test] + fn grant_and_revoke_need_the_scope_and_the_ability() { + let _lock = crate::runtime::lock_test_data_path(); + for uid in [110, 111, 112, 113] { + wipe(uid); + } + // A grantor with permission.grant and card.upload can hand out + // card.upload and nothing else - escalation is impossible + insert(110, PERMISSION_GRANT, 0); + insert(110, CARD_UPLOAD, 0); + grant(111, CARD_UPLOAD, 110).unwrap(); + grant(111, CARD_UPLOAD, 110).unwrap(); // idempotent + assert_eq!(held_scopes(111), vec![String::from(CARD_UPLOAD)]); + assert!(grant(111, CARD_EDIT, 110).is_err()); + assert!(grant(111, CARD, 110).is_err()); + assert!(grant(110, ALL, 110).is_err()); + assert!(!has(111, CARD_EDIT)); + + // No permission.grant / permission.revoke - no managing at all + insert(112, ALL, 0); + insert(113, CARD_UPLOAD, 0); + assert!(grant(113, CARD_UPLOAD, 113).is_err()); + assert!(revoke(112, ALL, 113).is_err()); + + // Revoking needs the revoked scope held too + insert(110, PERMISSION_REVOKE, 0); + assert!(revoke(112, ALL, 110).is_err()); + assert!(has(112, ALL)); + revoke(111, CARD_UPLOAD, 110).unwrap(); + assert!(!has(111, CARD_UPLOAD)); + + for uid in [110, 111, 112, 113] { + wipe(uid); + } + } + + #[test] + fn unknown_scopes_are_rejected() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(116); + wipe(117); + insert(116, ALL, 0); + assert!(grant(117, "card.explode", 116).is_err()); + assert!(revoke(117, "card.explode", 116).is_err()); + assert!(grant(117, "song.upload", 116).is_err()); + assert!(grant(0, CARD_UPLOAD, 116).is_err()); + wipe(116); + wipe(117); + } + + #[test] + fn owners_hold_everything_without_a_row() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(118); + wipe(119); + crate::runtime::update_owners(&[118, 120]); + for scope in SCOPES { + assert!(has(118, scope), "scope {}", scope); + assert!(has(120, scope), "scope {}", scope); + } + assert_eq!(scopes_for(118).len(), 1); + assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL)); + assert!(held_scopes(118).is_empty()); + // An owner can bootstrap-grant, and can't be revoked + grant(119, ALL, 118).unwrap(); + assert!(has(119, ALL)); + insert(118, CARD_UPLOAD, 0); + assert!(revoke(118, CARD_UPLOAD, 119).is_err()); + assert!(has(118, CARD_UPLOAD)); + // Dropping owner status drops the implicit "*" + crate::runtime::update_owners(&[]); + assert!(!has(118, CARD_EDIT)); + wipe(118); + wipe(119); + } + + #[test] + fn the_vocabulary_is_wellformed() { + for scope in SCOPES { + assert!(!scope.is_empty()); + assert!(!scope.ends_with('.')); + } + for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE] { + assert!(SCOPES.contains(&scope), "scope {}", scope); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 1c07850..1ae827b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,8 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> { let args = get_args(); let port = args.port; + runtime::update_owners(&args.owner); + if args.purge { println!("Purging accounts..."); let ct = crate::router::userdata::purge_accounts(); diff --git a/src/options.rs b/src/options.rs index 9feff6d..484ad1f 100644 --- a/src/options.rs +++ b/src/options.rs @@ -41,6 +41,12 @@ pub struct Args { #[arg(long, default_value_t = false, help = "Enable the custom songs feature (upload/browse/download). Disabled by default; every custom-songs endpoint and webui element is hidden unless this is set")] pub enable_custom_songs: bool, + #[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")] + pub enable_custom_cards: bool, + + #[arg(long, 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, + #[arg(long, default_value_t = false, help = "Purge dead user accounts on startup")] pub purge: bool, diff --git a/src/router.rs b/src/router.rs index 3c94495..88537b5 100644 --- a/src/router.rs +++ b/src/router.rs @@ -21,6 +21,7 @@ pub mod web; pub mod card; pub mod shop; pub mod custom_song; +pub mod custom_card; pub mod webui; pub mod clear_rate; pub mod exchange; @@ -217,6 +218,8 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/import" => webui::import(req, body), "/api/webui/set_time" => webui::set_time(req, body), "/api/webui/cheat" => webui::cheat(req, body), + "/api/webui/grantPermission" => webui::grant_permission(req, body), + "/api/webui/revokePermission" => webui::revoke_permission(req, body), _ => api_req(req, body).await } } else { @@ -231,6 +234,11 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/listMusic" => webui::get_music_info(req), "/api/webui/listLoginBonus" => webui::list_login_bonus(req), "/api/webui/listItems" => webui::list_items(req), + "/api/webui/listPermissions" => webui::list_permissions(req), + "/api/webui/listCharacters" => webui::list_characters(req), + "/api/webui/listSkillCenters" => webui::list_skill_centers(req), + "/api/webui/customCardLimits" => webui::custom_card_limits(req), + "/api/webui/myScopes" => webui::my_scopes(req), _ => api_req(req, body).await } } @@ -253,6 +261,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { .configure(card::routes) .configure(chat::routes) .configure(custom_song::routes) + .configure(custom_card::routes) .configure(debug::routes) .configure(event::routes) .configure(exchange::routes) @@ -279,4 +288,5 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { .configure(gree::routes) ); cfg.configure(custom_song::web_routes); + cfg.configure(custom_card::web_routes); } diff --git a/src/router/card.rs b/src/router/card.rs index 628eb93..1cc8bab 100644 --- a/src/router/card.rs +++ b/src/router/card.rs @@ -33,8 +33,11 @@ pub fn account_has_custom_cards(user: &JsonValue) -> bool { user["card_list"].members().any(|card| is_custom(card["master_card_id"].as_i64().unwrap_or(0))) } +// Card rows come through custom_card::card_info: the baked CARD_LIST for +// official/imported ids, the custom-card db for the runtime band - a runtime +// card has no CARD_LIST row and would otherwise cap at 0 here fn exp_cap(master_card_id: i64, evolved: bool) -> i64 { - let card = &databases::CARD_LIST[master_card_id.to_string()]; + let card = crate::router::custom_card::card_info(master_card_id); let rarity = card["rarity"].to_string(); let curve = card["masterCardLevelId"].as_i64().unwrap_or(0); let max_level = if evolved { @@ -46,7 +49,7 @@ fn exp_cap(master_card_id: i64, evolved: bool) -> i64 { } fn skill_exp_cap(master_card_id: i64) -> i64 { - let card = &databases::CARD_LIST[master_card_id.to_string()]; + let card = crate::router::custom_card::card_info(master_card_id); let rarity = card["rarity"].to_string(); let skill_curve = databases::CARD_RARITY[&rarity]["masterCardSkillLevelId"].to_string(); databases::CARD_SKILL_MAX[skill_curve].as_i64().unwrap_or(i64::MAX) diff --git a/src/router/clear_rate.rs b/src/router/clear_rate.rs index e185b0f..40b811d 100644 --- a/src/router/clear_rate.rs +++ b/src/router/clear_rate.rs @@ -255,7 +255,7 @@ pub async fn clearrate(req: HttpRequest) -> impl Responder { } pub async fn ranking(req: HttpRequest, Session { key, body }: Session) -> impl Responder { - let custom_cards = crate::router::card::client_supports_custom_cards(&req); + let protocol = crate::router::global::client_protocol_version(&req); let self_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); let live = body["master_live_id"].as_i64().unwrap(); @@ -266,15 +266,13 @@ pub async fn ranking(req: HttpRequest, Session { key, body }: Session) -> impl R for (i, data) in scores.members().enumerate() { let uid = data["user"].as_i64().unwrap(); - let user = guest::get_user(uid, &object![], guest::UserView::Ranking, custom_cards); + let user = guest::get_user(uid, &object![], guest::UserView::Ranking, protocol); let user_obj = if uid == self_id { // The client wants the fields get_user hides from other players let mut self_user = object!{ user: userdata::get_acc_from_uid(uid)["user"].clone() }; - if !custom_cards { - guest::proxy_user_cards(&mut self_user); - } + guest::proxy_user_cards(&mut self_user, protocol); self_user["user"].clone() } else { user["user"].clone() diff --git a/src/router/custom_card.rs b/src/router/custom_card.rs new file mode 100644 index 0000000..b595c06 --- /dev/null +++ b/src/router/custom_card.rs @@ -0,0 +1,1748 @@ +mod art; + +use jzon::{array, object, JsonValue}; +use actix_web::{web, HttpRequest, HttpResponse, Responder, http::header::ContentType}; +use actix_multipart::Multipart; +use futures_util::TryStreamExt; +use lazy_static::lazy_static; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::sync::Mutex; + +use crate::router::{databases, global, userdata, webui, Login, Api}; +use crate::router::databases::csv::{table, Region}; +use crate::database::custom_card as database; +use crate::database::permissions; +use crate::runtime::get_data_path; +use crate::lock_onto_mutex; + +// Runtime-uploaded cards and characters. Unlike the SIF1 import (id prefixes +// 10000-14999, baked into client masterdata at build time) these rows do not +// exist in any shipped table: the client fetches them from +// /api/custom_card/list at login (protocol version 3) and appends them to its +// Mst tables before the first payload carrying card_list arrives. +// +// Cards are owned by their uploader. Draft by default: a draft is served to +// its owner's catalog only. Publishing puts it in everyone's catalog, and +// "obtainable" additionally enters it into the client-synthesized custom +// gacha banner (lottery id 6900001), whose draw lottery.rs special-cases. +// Filtering is at the CATALOG level; the art GET is content-addressed and +// sessionless, like a CDN. +// +// Storage layout (under --path): +// custom_cards/{master_card_id}/{kind}_{variant}.png c_00.png ... sc_01.png +// custom_cards/characters/{master_character_id}/{kind}.png +// Metadata lives in custom_cards.db as one JSON blob per card / character, in +// the exact shape /api/custom_card/list serves. + +// Level 1 = custom songs, 2 = resolves the baked SIF1-import band, 3 = fetches +// the runtime custom-card catalog +pub const PROTOCOL_VERSION: u32 = 3; + +// The client-synthesized custom gacha banner. The 6M band is reserved for +// custom lotteries (the baked SIF1 banners are 6110001-6110004) +pub const CUSTOM_LOTTERY_ID: i64 = 6_900_001; + +// Upload limits, enforced while the multipart field is still streaming (the +// 25MB PayloadConfig in lib.rs binds the String/Bytes extractors, not +// Multipart). A card upload carries 14 png files, so the per-request cap is +// the binding one +pub const MAX_FILE_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_REQUEST_BYTES: usize = 64 * 1024 * 1024; +pub const MAX_CARDS_PER_USER: i64 = 500; + +// Columns the uploader never supplies. master_release_label_id must be 1: a +// closed label filters the card out of the member-list filters and drops its +// evolve conditions +const MASTER_RELEASE_LABEL_ID: i64 = 1; +// card_get.csv category 1 = GACHA, matching how the card is actually obtained +const GET_CATEGORY_GACHA: i64 = 1; + +// Every one of the 172 imported characters carries exactly these values +// (csv/character.csv), and category 6 (BAND_CATEGORY OTHER) is not cosmetic - +// SDCharacter substitutes the 99999 atlas for an OTHER-category character, +// which is what gives a new character a working SD chibi with no new asset. +// master_group_id 9000 is a real group id in character_group.csv - a +// nonexistent group id is the class of bug that KeyNotFound-crashed custom +// songs. These are NUMBERS on the wire, never enum names +const CHARACTER_CATEGORY_OTHER: i64 = 6; +const CHARACTER_CHARA_CATEGORY: i64 = 1; +const CHARACTER_GROUP_ID: i64 = 9000; +const CHARACTER_SCHOOL_GRADE: i64 = 0; + +// Client enum ranges (crash ranges, not cosmetic: the member sorter indexes +// arrays sized to the enum with the raw value). Kept as named consts in ONE +// place; the client agent is re-verifying the exact ceilings +// 0 = NONE exists in the client enum but no shipped skill row uses it (a NONE +// live skill would do nothing), so uploads start at 1 +const SKILL_EFFECT_TYPE_MIN: i64 = 1; +const SKILL_EFFECT_TYPE_MAX: i64 = 11; +const SKILL_TRIGGER_MIN: i64 = 1; +const SKILL_TRIGGER_MAX: i64 = 4; +const SKILL_SUB_TARGET_MAX: i64 = 1; +const SKILL_SCHOOL_GRADE_MAX: i64 = 3; +const CARD_TYPE_MIN: i64 = 1; +const CARD_TYPE_MAX: i64 = 4; +const CARD_RARITY_MIN: i64 = 1; +const CARD_RARITY_MAX: i64 = 3; +const RARITY_NAMES: &[&str] = &["R", "SR", "UR"]; +// Sanity ceilings for the level-indexed skill arrays +const SKILL_PROBABILITY_MAX: i64 = 1_000_000; +const SKILL_MILLI_SECS_MAX: i64 = 600_000; + +struct ArtKind { + kind: &'static str, + width: u32, + height: u32 +} + +// Official target dimensions, verified against the shipped art. Every kind is +// DERIVED from the per-variant source artwork (art_00 / art_01) by the SIF1 +// import pipeline's recipes (see art.rs); an explicitly supplied kind file +// overrides the derived one and is itself cover-cropped + resized to target, +// never rejected for dimensions. The stored/hashed bytes are always the +// processed PNG +const CARD_ART: &[ArtKind] = &[ + ArtKind { kind: "c", width: 2048, height: 1260 }, + ArtKind { kind: "h", width: 2048, height: 1260 }, + ArtKind { kind: "t", width: 512, height: 315 }, + ArtKind { kind: "p", width: 136, height: 508 }, + ArtKind { kind: "r", width: 256, height: 256 }, + ArtKind { kind: "m", width: 380, height: 380 }, + ArtKind { kind: "sc", width: 1024, height: 512 } +]; + +// "00" = base, "01" = evolved. Both are required for every kind: the client +// picks evolve ? evolve_illust_id : illust_id with no rarity gate +const CARD_ART_VARIANTS: &[&str] = &["00", "01"]; + +const CHARACTER_ART: &[ArtKind] = &[ + ArtKind { kind: "pr", width: 512, height: 615 }, + ArtKind { kind: "icon", width: 230, height: 230 }, + ArtKind { kind: "sign", width: 300, height: 330 }, + ArtKind { kind: "character", width: 600, height: 920 } +]; + +type Fields = HashMap>; + +lazy_static! { + // Id allocation and the insert must not race between two uploads + static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(()); + + static ref OFFICIAL_CHARACTER_IDS: HashSet = { + table(Region::Jp, "character").members().filter_map(|row| row["id"].as_i64()).collect() + }; + + static ref SKILL_CENTER_IDS: HashSet = { + table(Region::Jp, "skill_center").members().filter_map(|row| row["id"].as_i64()).collect() + }; + + // Real GroupMst ids, from the character->group mapping. skill + // target_group_id must be 0 or one of these + static ref GROUP_IDS: HashSet = { + table(Region::Jp, "character_group").members().filter_map(|row| row["groupId"].as_i64()).collect() + }; + + // rarity -> how many skill levels its curve has (3 / 5 / 9). The client + // indexes the level arrays in lockstep with the skill level, so this is + // the required length of every level-indexed array + static ref SKILL_LEVEL_COUNT: HashMap = { + let mut per_curve: HashMap = HashMap::new(); + for row in table(Region::Jp, "card_skill_level").members() { + if let Some(id) = row["id"].as_i64() { + *per_curve.entry(id).or_insert(0) += 1; + } + } + let mut rv = HashMap::new(); + for row in table(Region::Jp, "card_rarity").members() { + let (Some(rarity), Some(curve)) = (row["rarity"].as_i64(), row["masterCardSkillLevelId"].as_i64()) else { continue; }; + rv.insert(rarity, *per_curve.get(&curve).unwrap_or(&0)); + } + rv + }; + + // rarity -> (hp, smile, cool, pure) ceilings: the maximum any official + // card of that rarity reaches. do_reinforce trusts the stored card + // completely, so an uploaded stat is permanent - cap it at upload + static ref STAT_CAPS: HashMap = { + let mut rv: HashMap = HashMap::new(); + for row in databases::CARD_LIST.entries() { + let card = row.1; + let Some(id) = card["id"].as_i64() else { continue; }; + // The imported band shares official stat scales; both are fine as + // a ceiling source, runtime cards are excluded by construction + if id >= database::FIRST_CARD_ID { + continue; + } + let Some(rarity) = card["rarity"].as_i64() else { continue; }; + let entry = rv.entry(rarity).or_insert((0, 0, 0, 0)); + entry.0 = entry.0.max(card["hp"].as_i64().unwrap_or(0)); + entry.1 = entry.1.max(card["smile"].as_i64().unwrap_or(0)); + entry.2 = entry.2.max(card["cool"].as_i64().unwrap_or(0)); + entry.3 = entry.3.max(card["pure"].as_i64().unwrap_or(0)); + } + rv + }; +} + +// Game endpoints (/api scope, standard envelope) +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/custom_card") + .route("/list", web::post().to(list)) + ); +} + +// Plain art GET for the game + session-authenticated management API for the +// webui. Mounted OUTSIDE /api so the game middlewares never wrap it +pub fn web_routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/custom_card") + .route("/data/{hash}/{file}", web::get().to(data)) + .route("/create", web::post().to(create)) + .route("/update", web::post().to(update)) + .route("/publish", web::post().to(publish)) + .route("/delete", web::post().to(delete)) + .route("/mine", web::get().to(mine)) + .route("/browse", web::get().to(browse)) + .route("/character/create", web::post().to(character_create)) + .route("/character/update", web::post().to(character_update)) + .route("/character/delete", web::post().to(character_delete)) + ); +} + +// The whole feature is opt-in (--enable-custom-cards) and additionally off in +// --hidden mode. When disabled every endpoint 404s / errors as if it never +// existed, nothing touches custom_cards.db (so no table setup runs), and no +// runtime card ever resolves +pub fn disabled() -> bool { + let args = crate::get_args(); + args.hidden || !args.enable_custom_cards +} + +// The runtime-uploaded band. card::is_custom covers 100M+ (baked import +// included); this is the narrower "not in any shipped masterdata" test +pub fn is_custom_runtime(master_card_id: i64) -> bool { + (database::FIRST_CARD_ID..=database::LAST_CARD_ID).contains(&master_card_id) +} + +pub fn client_supports(req: &HttpRequest) -> bool { + global::client_protocol_version(req) >= PROTOCOL_VERSION +} + +pub fn account_supports(auth_key: &str) -> bool { + userdata::get_protocol_version(auth_key) >= PROTOCOL_VERSION +} + +// Whether a viewer on this protocol version can turn `master_card_id` into a +// card row. "Supports the protocol" is not "has this card in its catalog": a +// draft (or a card published after the viewer logged in) is unresolvable by a +// fully modern client, and the client throws on an unknown id rather than +// degrading. The protocol test comes first so an older viewer never touches +// custom_cards.db +pub fn viewer_can_resolve(master_card_id: i64, protocol: u32) -> bool { + if !crate::router::card::is_custom(master_card_id) { + return true; + } + if !is_custom_runtime(master_card_id) { + return protocol >= crate::router::card::PROTOCOL_VERSION; + } + if disabled() || protocol < PROTOCOL_VERSION { + return false; + } + database::is_published(master_card_id) +} + +// The character a runtime card belongs to, for guest::proxy_card_id +pub fn character_of(master_card_id: i64) -> Option { + if disabled() { + return None; + } + database::character_of(master_card_id) +} + +// The card row for any master_card_id, in the csv CARD_LIST shape. Official +// and imported cards come straight from the baked table; runtime cards are +// synthesized from the db blob so the CARD_LIST consumers (reinforce, evolve, +// rarity, bond) keep working on them. Null when the id doesn't exist anywhere +pub fn card_info(master_card_id: i64) -> JsonValue { + let official = &databases::CARD_LIST[master_card_id.to_string()]; + if !official.is_empty() { + return official.clone(); + } + if !is_custom_runtime(master_card_id) || disabled() { + return JsonValue::Null; + } + let Some(card) = database::get_card(master_card_id) else { + return JsonValue::Null; + }; + object!{ + "id": master_card_id, + "masterCharacterId": card["master_character_id"].clone(), + "type": card["type"].clone(), + "rarity": card["rarity"].clone(), + "masterCardLevelId": card["master_card_level_id"].clone() + } +} + +// Runtime-band cards are unresolvable by a client that can't fetch the +// catalog, and one unknown id in card_list aborts the whole login. start.rs +// blocks flagged accounts on old clients, so this is belt-and-braces for the +// shared /api/user response +pub fn strip_unsupported(user: &mut JsonValue) { + let mut dropped = array![]; + let mut card_list = array![]; + for card in user["card_list"].members() { + let id = card["master_card_id"].as_i64().unwrap_or(0); + if is_custom_runtime(id) { + dropped.push(id).unwrap(); + continue; + } + card_list.push(card.clone()).unwrap(); + } + if dropped.is_empty() { + return; + } + user["card_list"] = card_list; + for deck in user["deck_list"].members_mut() { + for id in deck["main_card_ids"].members_mut() { + if dropped.contains(id.as_i64().unwrap_or(0)) { + *id = (0).into(); + } + } + } + for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] { + if dropped.contains(user["user"][key].as_i64().unwrap_or(0)) { + user["user"][key] = (0).into(); + } + } +} + +// The concrete upload bounds, served to the webui so the form can enforce +// them client-side (sliders/radios) and reject out-of-range values instantly +pub fn upload_limits() -> JsonValue { + let mut stat_caps = object!{}; + let mut skill_levels = object!{}; + for rarity in CARD_RARITY_MIN..=CARD_RARITY_MAX { + let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)); + stat_caps[rarity.to_string()] = object!{ + "hp": caps.0, + "smile": caps.1, + "cool": caps.2, + "pure": caps.3 + }; + skill_levels[rarity.to_string()] = (*SKILL_LEVEL_COUNT.get(&rarity).unwrap_or(&0)).into(); + } + // The real groups a skill may target, with display names for the webui's + // dropdown (nobody knows the raw group ids) + let mut rows: Vec<(i64, JsonValue)> = table(Region::Jp, "group").members() + .filter_map(|row| Some((row["id"].as_i64()?, row.clone()))) + .collect(); + rows.sort_by_key(|(id, _)| *id); + let mut groups = array![]; + for (id, row) in rows { + groups.push(object!{ + "id": id, + "name": row["name"].clone(), + "name_en": row["nameEn"].clone() + }).unwrap(); + } + object!{ + "stat_caps": stat_caps, + "skill_levels": skill_levels, + "groups": groups, + "trigger_min": SKILL_TRIGGER_MIN, + "trigger_max": SKILL_TRIGGER_MAX, + "effect_type_min": SKILL_EFFECT_TYPE_MIN, + "effect_type_max": SKILL_EFFECT_TYPE_MAX, + "sub_target_max": SKILL_SUB_TARGET_MAX, + "school_grade_max": SKILL_SCHOOL_GRADE_MAX, + "probability_max": SKILL_PROBABILITY_MAX, + "milli_secs_max": SKILL_MILLI_SECS_MAX, + "min_source_dim": art::MIN_SOURCE_DIM + } +} + +// The runtime card ids in a game account's card_list +pub fn owned_runtime_ids(user: &JsonValue) -> Vec { + user["card_list"].members() + .filter_map(|card| card["master_card_id"].as_i64()) + .filter(|id| is_custom_runtime(*id)) + .collect() +} + +// The catalog is filtered per requesting user: everyone gets the published +// cards, the owner additionally gets their drafts, and a game account that +// already owns a card keeps resolving it even if it was since unpublished +async fn list(Login(key): Login) -> impl Responder { + if disabled() { + // As if the endpoint doesn't exist - the client treats this as feature-off + return Api(None); + } + let user = userdata::get_acc(&key); + let uid = user["user"]["id"].as_i64().unwrap(); + let cards = database::get_cards_for_user(uid, &owned_runtime_ids(&user)); + let characters = database::get_characters_for_cards(uid, &cards); + Api(Some(object!{ + "revision": database::get_revision(), + "characters": characters, + "cards": cards + })) +} + +fn card_dir(master_card_id: i64) -> String { + get_data_path(&format!("custom_cards/{}", master_card_id)) +} + +fn character_dir(master_character_id: i64) -> String { + get_data_path(&format!("custom_cards/characters/{}", master_character_id)) +} + +fn asset_path(relative: &str) -> String { + get_data_path(&format!("custom_cards/{}", relative)) +} + +// Content-addressed art fetch: '{server}/custom_card/data/{md5}/{md5}.png'. +// The game builds the URL from the md5 it read in the catalog and caches by +// it, so a stale md5 simply 404s and the client re-downloads under the new +// one. Visible to all like the custom-song data route (CDN semantics) - only +// the feature flag gates it +async fn data(req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let hash = req.match_info().get("hash").unwrap_or("").to_string(); + let file = req.match_info().get("file").unwrap_or("").to_string(); + if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) || !file.starts_with(&format!("{}.", hash)) { + return HttpResponse::NotFound().finish(); + } + let Some(relative) = database::find_asset_by_md5(&hash) else { + return HttpResponse::NotFound().finish(); + }; + match fs::read(asset_path(&relative)) { + Ok(body) => { + HttpResponse::Ok() + .insert_header(ContentType::png()) + .insert_header(("content-length", body.len())) + .body(body) + }, + Err(_) => HttpResponse::NotFound().finish() + } +} + +fn get_session_uid(req: &HttpRequest) -> Option { + 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 send_json(resp: JsonValue) -> HttpResponse { + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// The per-file cap is enforced while the field is still streaming, BEFORE any +// byte reaches the png decoder, so a decompression bomb is never decoded. The +// per-request cap is checked over the running total +async fn read_multipart(mut payload: Multipart) -> Result { + 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_REQUEST_BYTES { + return Err(format!("Upload exceeds the {} MB per-request limit", MAX_REQUEST_BYTES / (1024 * 1024))); + } + data.extend_from_slice(&chunk); + if data.len() > MAX_FILE_BYTES { + return Err(format!("'{}' exceeds the {} MB per-file limit", name, MAX_FILE_BYTES / (1024 * 1024))); + } + } + 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() +} + +// Checkbox-style flag: "1", "true" or "on" +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> { + fields.get(key).filter(|v| !v.is_empty()) +} + +// Partial-edit semantics for update: a field present in the form replaces the +// stored value, an absent one keeps it. On create `stored` is empty, so every +// absent field simply reads as empty/invalid and fails its own validation +fn text_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> String { + if fields.contains_key(key) { + field_str(fields, key) + } else { + stored[stored_key].as_str().unwrap_or("").to_string() + } +} + +fn number_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> i64 { + if fields.contains_key(key) { + field_str(fields, key).parse::().unwrap_or(i64::MIN) + } else { + stored[stored_key].as_i64().unwrap_or(i64::MIN) + } +} + +// Masterdata writes level-indexed skill arrays slash-separated ("25/24/24"); +// an HTML form is more naturally comma-separated. Both are accepted +fn parse_levels(raw: &str, label: &str) -> Result, String> { + let mut rv = Vec::new(); + for part in raw.split(['/', ',']) { + let part = part.trim(); + if part.is_empty() { + continue; + } + let value = part.parse::().map_err(|_| format!("{}: '{}' is not a number", label, part))?; + if value < 0 || value > u32::MAX as i64 { + return Err(format!("{}: '{}' does not fit in a uint", label, part)); + } + rv.push(value); + } + Ok(rv) +} + +fn levels_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> Result, String> { + if fields.contains_key(key) { + return parse_levels(&field_str(fields, key), key); + } + Ok(stored[stored_key].members().filter_map(|v| v.as_i64()).collect()) +} + +fn to_json_array(values: &[i64]) -> JsonValue { + let mut rv = array![]; + for value in values { + rv.push(*value).unwrap(); + } + rv +} + +// illust_id is {prefix:05}_{seq:04}_{00|01}, derived from the id and never +// uploaded +fn illust_id(master_card_id: i64, variant: &str) -> String { + format!("{:05}_{:04}_{}", master_card_id / 10000, master_card_id % 10000, variant) +} + +struct PendingArt { + file: String, + entry: JsonValue, + bytes: Vec +} + +fn pending(kind: &str, variant: Option<&str>, png: Vec) -> PendingArt { + let name = match variant { + Some(variant) => format!("{}_{}", kind, variant), + None => kind.to_string() + }; + let mut entry = object!{ + "kind": kind, + "md5": format!("{:x}", md5::compute(&png)), + "size": png.len() + }; + if let Some(variant) = variant { + entry["variant"] = variant.into(); + } + PendingArt { + file: format!("{}.png", name), + entry, + bytes: png + } +} + +fn oversized(name: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.len() > MAX_FILE_BYTES { + return Err(format!("'{}' exceeds the {} MB per-file limit", name, MAX_FILE_BYTES / (1024 * 1024))); + } + Ok(()) +} + +// An explicit per-kind override: any decodable format, any dimensions - +// cover-cropped to the target aspect and resized, then stored as PNG +fn process_override(name: &str, bytes: &[u8], kind: &ArtKind) -> Result, String> { + oversized(name, bytes)?; + let img = art::decode_source(name, bytes)?; + art::encode_png(&art::cover(&img.to_rgba8(), kind.width, kind.height)) +} + +// Card art per variant: the source artwork (art_00 / art_01) derives all 7 +// kinds via the import pipeline's recipes; explicit per-kind files override +// the derived ones. On create both sources are required; on update an absent +// source keeps the stored art (individual overrides still replace their kind) +fn collect_card_art(fields: &Fields, require_sources: bool) -> Result, String> { + let mut rv = Vec::new(); + for variant in CARD_ART_VARIANTS { + let source_name = format!("art_{}", variant); + let mut derived: HashMap<&'static str, image::RgbaImage> = HashMap::new(); + if let Some(bytes) = file_of(fields, &source_name) { + oversized(&source_name, bytes)?; + let img = art::decode_source(&source_name, bytes)?; + derived = art::derive_card_art(&img).into_iter().collect(); + } else if require_sources { + let label = if *variant == "00" { "normal" } else { "evolved" }; + return Err(format!("'{}' ({} card artwork) is required", source_name, label)); + } + for kind in CARD_ART { + let name = format!("{}_{}", kind.kind, variant); + let png = if let Some(bytes) = file_of(fields, &name) { + Some(process_override(&name, bytes, kind)?) + } else if let Some(img) = derived.remove(kind.kind) { + Some(art::encode_png(&img)?) + } else { + None + }; + if let Some(png) = png { + rv.push(pending(kind.kind, Some(variant), png)); + } + } + } + Ok(rv) +} + +// Character art: the portrait (pr), signature (sign) and standing art +// (character) are distinct content and stay separate inputs; the icon is +// derived from the portrait unless explicitly supplied. Everything is +// cover-cropped to target, never rejected for dimensions +fn collect_character_art(fields: &Fields, require_all: bool) -> Result, String> { + let mut rv = Vec::new(); + let mut portrait: Option = None; + for kind in CHARACTER_ART { + let png = if let Some(bytes) = file_of(fields, kind.kind) { + oversized(kind.kind, bytes)?; + let img = art::decode_source(kind.kind, bytes)?; + let png = art::encode_png(&art::cover(&img.to_rgba8(), kind.width, kind.height))?; + if kind.kind == "pr" { + portrait = Some(img); + } + Some(png) + } else if kind.kind == "icon" { + // Derived from the portrait; on an update with no new portrait + // the stored icon stays + portrait.as_ref().map(|img| art::encode_png(&art::derive_character_icon(img))).transpose()? + } else if require_all { + return Err(format!("'{}' art is required", kind.kind)); + } else { + None + }; + if let Some(png) = png { + rv.push(pending(kind.kind, None, png)); + } + } + Ok(rv) +} + +// The catalog's art list: the stored entries with every replaced file swapped +// out. The (kind, variant) pair is the client's cache key and md5 is the hash +// of the exact bytes the data route serves +fn merge_art(stored: &JsonValue, pending: &[PendingArt]) -> JsonValue { + let mut rv = array![]; + for art in stored.members() { + if pending.iter().any(|new| new.entry["kind"] == art["kind"] && new.entry["variant"] == art["variant"]) { + continue; + } + rv.push(art.clone()).unwrap(); + } + for art in pending { + rv.push(art.entry.clone()).unwrap(); + } + rv +} + +fn write_art(dir: &str, pending: &[PendingArt]) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + for art in pending { + fs::write(format!("{}/{}", dir, art.file), &art.bytes).map_err(|e| e.to_string())?; + } + Ok(()) +} + +// card.upload manages your own uploads, card.edit is moderation over anybody's +fn can_manage(uid: i64, owner: i64) -> bool { + permissions::has(uid, permissions::CARD_EDIT) + || (owner == uid && permissions::has(uid, permissions::CARD_UPLOAD)) +} + +fn can_publish(uid: i64, owner: i64) -> bool { + permissions::has(uid, permissions::CARD_EDIT) + || (owner == uid && permissions::has(uid, permissions::CARD_PUBLISH)) +} + +// A card may reference an official/imported character, or a custom one the +// uploader owns (or that is already publicly visible through a published card) +fn validate_character_ref(uid: i64, master_character_id: i64) -> Result<(), String> { + if OFFICIAL_CHARACTER_IDS.contains(&master_character_id) { + return Ok(()); + } + if database::has_character(master_character_id) + && (database::get_character_owner(master_character_id) == Some(uid) + || database::character_publicly_visible(master_character_id) + || permissions::has(uid, permissions::CARD_EDIT)) { + return Ok(()); + } + Err(format!("Unknown master_character_id '{}'", master_character_id)) +} + +// Every referential and range check the client cannot survive being wrong +// about. Returns the catalog blob, ready to store and serve verbatim +fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result { + for (key, label) in [("name", "Card name"), ("name_en", "Card English name")] { + if text_of(fields, key, stored, key).is_empty() { + return Err(format!("{} is required", label)); + } + } + + let card_type = number_of(fields, "type", stored, "type"); + if !(CARD_TYPE_MIN..=CARD_TYPE_MAX).contains(&card_type) { + return Err(String::from("type must be 1-4 (1 Smile / 2 Pure / 3 Cool / 4 All)")); + } + + let rarity = number_of(fields, "rarity", stored, "rarity"); + if !(CARD_RARITY_MIN..=CARD_RARITY_MAX).contains(&rarity) { + return Err(String::from("rarity must be 1-3 (1 R / 2 SR / 3 UR)")); + } + let rarity_name = RARITY_NAMES[(rarity - 1) as usize]; + + // 0 is never a valid skill_center row: the client dereferences the mst + // with no null filter + let master_skill_center_id = number_of(fields, "master_skill_center_id", stored, "master_skill_center_id"); + if !SKILL_CENTER_IDS.contains(&master_skill_center_id) { + return Err(format!("Unknown master_skill_center_id '{}'", master_skill_center_id)); + } + + // Note the official HP scale before assuming a bug report: hp is a tiny + // per-rarity constant in SIF2 (every official R card has 2, SR 3, UR 4) + let caps = STAT_CAPS.get(&rarity).copied().unwrap_or((0, 0, 0, 0)); + let stats = [("hp", caps.0), ("smile", caps.1), ("cool", caps.2), ("pure", caps.3)]; + let mut values = Vec::new(); + for (key, cap) in stats { + let value = number_of(fields, key, stored, key); + if !(1..=cap).contains(&value) { + return Err(format!("{} must be between 1 and {} for a {} card (the official {} range)", key, cap, rarity_name, rarity_name)); + } + values.push(value); + } + + let levels = *SKILL_LEVEL_COUNT.get(&rarity).unwrap_or(&0); + let stored_skill = stored["skill"].clone(); + + for (key, label) in [ + ("skill_name", "Skill name"), + ("skill_name_en", "Skill English name"), + ("skill_detail_text", "Skill description"), + ("skill_detail_text_en", "Skill English description") + ] { + if text_of(fields, key, &stored_skill, &key["skill_".len()..]).is_empty() { + return Err(format!("{} is required", label)); + } + } + + let trigger = number_of(fields, "skill_trigger", &stored_skill, "trigger"); + if !(SKILL_TRIGGER_MIN..=SKILL_TRIGGER_MAX).contains(&trigger) { + return Err(String::from("skill_trigger must be 1-4 (1 rhythm icons / 2 combo / 3 PERFECTs / 4 seconds)")); + } + let effect_type = number_of(fields, "skill_effect_type", &stored_skill, "effect_type"); + if !(SKILL_EFFECT_TYPE_MIN..=SKILL_EFFECT_TYPE_MAX).contains(&effect_type) { + return Err(String::from("skill_effect_type must be 1-11 (1-3 stat up / 4 score / 5 perfect window / 6 heal / 7 skill chance / 8 skill boost / 9 param sync / 10 combo fever / 11 skill repeat)")); + } + let sub_target = number_of(fields, "skill_sub_target", &stored_skill, "sub_target"); + if !(0..=SKILL_SUB_TARGET_MAX).contains(&sub_target) { + return Err(String::from("skill_sub_target must be 0 or 1 (0 every rhythm icon / 1 PERFECT icons only)")); + } + let target_group_id = number_of(fields, "skill_target_group_id", &stored_skill, "target_group_id"); + if target_group_id != 0 && !GROUP_IDS.contains(&target_group_id) { + return Err(format!("skill_target_group_id must be 0 or a real group id, got '{}'", target_group_id)); + } + let target_school_grade = number_of(fields, "skill_target_school_grade", &stored_skill, "target_school_grade"); + if !(0..=SKILL_SCHOOL_GRADE_MAX).contains(&target_school_grade) { + return Err(String::from("skill_target_school_grade must be 0-3 (0 = no grade filter)")); + } + + // The client walks these in lockstep with the skill level, so each must + // carry exactly one value per level of the rarity's curve. Shipped + // masterdata also allows a single constant duration, so + // effective_milli_secs may be 1 long + let mut arrays: HashMap<&str, Vec> = HashMap::new(); + for (form, key, max) in [ + ("skill_trigger_value", "trigger_value", u32::MAX as i64), + ("skill_probability", "probability", SKILL_PROBABILITY_MAX), + ("skill_effective_milli_secs", "effective_milli_secs", SKILL_MILLI_SECS_MAX), + ("skill_effective_values", "effective_values", u32::MAX as i64) + ] { + let values = levels_of(fields, form, &stored_skill, key)?; + if values.len() != levels && !(key == "effective_milli_secs" && values.len() == 1) { + return Err(format!("{} needs exactly {} values for a rarity {} card, got {}", form, levels, rarity, values.len())); + } + if let Some(value) = values.iter().find(|v| **v > max) { + return Err(format!("{}: '{}' exceeds the maximum of {}", form, value, max)); + } + arrays.insert(key, values); + } + + Ok(object!{ + "master_card_id": master_card_id, + "master_character_id": master_character_id, + "name": text_of(fields, "name", stored, "name"), + "name_en": text_of(fields, "name_en", stored, "name_en"), + "type": card_type, + "rarity": rarity, + "master_skill_center_id": master_skill_center_id, + "master_skill_id": master_card_id, + "hp": values[0], + "smile": values[1], + "cool": values[2], + "pure": values[3], + "illust_id": illust_id(master_card_id, "00"), + "evolve_illust_id": illust_id(master_card_id, "01"), + // Official cards use the level curve matching their rarity (1/2/3), + // verified across every row of card.csv + "master_card_level_id": rarity, + "unique_background_file_name": "", + "evolve_unique_background_file_name": "", + "get_category": GET_CATEGORY_GACHA, + "master_card_sys_voice_id": 0, + "album_unit_m_id": 0, + "priority": 0, + "master_release_label_id": MASTER_RELEASE_LABEL_ID, + "skill": { + "name": text_of(fields, "skill_name", &stored_skill, "name"), + "name_en": text_of(fields, "skill_name_en", &stored_skill, "name_en"), + "detail_text": text_of(fields, "skill_detail_text", &stored_skill, "detail_text"), + "detail_text_en": text_of(fields, "skill_detail_text_en", &stored_skill, "detail_text_en"), + "trigger": trigger, + "trigger_value": to_json_array(&arrays["trigger_value"]), + "probability": to_json_array(&arrays["probability"]), + "effective_milli_secs": to_json_array(&arrays["effective_milli_secs"]), + "sub_target": sub_target, + "target_group_id": target_group_id, + "target_school_grade": target_school_grade, + "effect_type": effect_type, + "effective_values": to_json_array(&arrays["effective_values"]) + }, + "art": JsonValue::Null // filled by the caller with merge_art + }) +} + +fn valid_color(color: &str) -> bool { + color.len() == 6 && color.chars().all(|c| c.is_ascii_hexdigit()) +} + +// Every column the uploader never supplies is forced to the value all 172 +// imported characters carry. Numbers, not enum names +fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result { + for (key, label) in [ + ("character_name", "Character name"), + ("character_name_en", "Character English name"), + ("character_name_ruby", "Character name reading"), + ("character_name_ruby_en", "Character English name reading"), + ("character_detail_text", "Character description"), + ("character_detail_text_en", "Character English description"), + ("character_name_richtext_gacha", "Character gacha display name"), + ("character_name_richtext_gacha_en", "Character English gacha display name") + ] { + if text_of(fields, key, stored, &key["character_".len()..]).is_empty() { + return Err(format!("{} is required", label)); + } + } + for key in ["character_image_color", "character_image_color_dark"] { + if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) { + return Err(format!("{} must be a 6-digit hex color like FF9210", key)); + } + } + let text = |key: &str| text_of(fields, &format!("character_{}", key), stored, key); + Ok(object!{ + "master_character_id": master_character_id, + "name": text("name"), + "name_en": text("name_en"), + "name_ruby": text("name_ruby"), + "name_ruby_en": text("name_ruby_en"), + "detail_text": text("detail_text"), + "detail_text_en": text("detail_text_en"), + "category": CHARACTER_CATEGORY_OTHER, + "school_grade": CHARACTER_SCHOOL_GRADE, + "chara_category": CHARACTER_CHARA_CATEGORY, + "master_group_id": CHARACTER_GROUP_ID, + "sprite_name": "", + "display_order": master_character_id, + "height": text("height"), + "blood_type": text("blood_type"), + "blood_type_en": text("blood_type_en"), + "birthday": text("birthday"), + "birthday_en": text("birthday_en"), + "voice_actor": text("voice_actor"), + "voice_actor_en": text("voice_actor_en"), + "image_color": text("image_color"), + "image_color_dark": text("image_color_dark"), + "name_richtext_gacha": text("name_richtext_gacha"), + "name_richtext_gacha_en": text("name_richtext_gacha_en"), + "master_release_label_id": MASTER_RELEASE_LABEL_ID, + "art": JsonValue::Null // filled by the caller with merge_art + }) +} + +pub fn create_card(uid: i64, fields: &Fields) -> Result { + if !permissions::has(uid, permissions::CARD_UPLOAD) { + return Err(String::from("You do not have permission to upload cards")); + } + if database::card_count_for_owner(uid) >= MAX_CARDS_PER_USER { + return Err(format!("You have reached the {} card limit", MAX_CARDS_PER_USER)); + } + let published = field_flag(fields, "published"); + let obtainable = field_flag(fields, "obtainable"); + if (published || obtainable) && !can_publish(uid, uid) { + return Err(String::from("You do not have permission to publish cards")); + } + + let master_character_id = field_str(fields, "master_character_id").parse::().unwrap_or(0); + validate_character_ref(uid, master_character_id)?; + + // Fail fast: every cheap field/enum/range/reference check runs (as a + // dry-run against a placeholder id) BEFORE any image is decoded or + // derived, so a form mistake rejects instantly instead of after seconds + // of art processing + build_card(0, master_character_id, fields, &object!{})?; + + let card_art = collect_card_art(fields, true)?; + + let lock = lock_onto_mutex!(UPLOAD_LOCK); + let master_card_id = database::next_card_id(); + if master_card_id > database::LAST_CARD_ID { + return Err(String::from("The custom card id space is exhausted")); + } + if !databases::CARD_LIST[master_card_id.to_string()].is_empty() { + return Err(format!("Card id {} already exists in masterdata", master_card_id)); + } + + let mut card = build_card(master_card_id, master_character_id, fields, &object!{})?; + card["art"] = merge_art(&array![], &card_art); + + write_art(&card_dir(master_card_id), &card_art)?; + database::insert_card(master_card_id, master_character_id, uid, &card, published, obtainable); + database::bump_revision(); + drop(lock); + + Ok(master_card_id) +} + +// Edit a card in place. The master_card_id - and everything derived from it: +// master_skill_id, illust ids - stays the same, so a player who owns the card +// keeps owning the same card. master_character_id is fixed too: repointing it +// would orphan a custom character mid-catalog +pub fn update_card(uid: i64, master_card_id: i64, fields: &Fields) -> Result<(), String> { + let Some(owner) = database::get_card_owner(master_card_id) else { + return Err(String::from("Card not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You do not have permission to edit this card")); + } + let stored = database::get_card(master_card_id).ok_or(String::from("Card not found"))?; + let master_character_id = stored["master_character_id"].as_i64().unwrap_or(0); + + // Field validation first (cheap), art processing second - fail fast + let mut card = build_card(master_card_id, master_character_id, fields, &stored)?; + let card_art = collect_card_art(fields, false)?; + card["art"] = merge_art(&stored["art"], &card_art); + + let lock = lock_onto_mutex!(UPLOAD_LOCK); + write_art(&card_dir(master_card_id), &card_art)?; + database::update_card(master_card_id, &card); + database::bump_revision(); + drop(lock); + + Ok(()) +} + +// Publish/unpublish and the obtainable toggle share a route: both are +// catalog-flag flips on an owned card +pub fn set_card_flags(uid: i64, master_card_id: i64, published: Option, obtainable: Option) -> Result<(), String> { + let Some(owner) = database::get_card_owner(master_card_id) else { + return Err(String::from("Card not found")); + }; + if !can_publish(uid, owner) { + return Err(String::from("You do not have permission to publish this card")); + } + if let Some(published) = published { + database::set_published(master_card_id, published); + } + if let Some(obtainable) = obtainable { + database::set_obtainable(master_card_id, obtainable); + } + database::bump_revision(); + Ok(()) +} + +// Deleting retires the id forever. Player copies of the dead card are wiped +// lazily on each account's next userdata pull (userdata::remove_deleted_ +// custom_cards), mirroring how deleted custom songs clean up. The character +// stays: it may back other cards, and has its own delete route +pub fn delete_card(uid: i64, master_card_id: i64) -> Result<(), String> { + let Some(owner) = database::get_card_owner(master_card_id) else { + return Err(String::from("Card not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You do not have permission to delete this card")); + } + let lock = lock_onto_mutex!(UPLOAD_LOCK); + database::delete_card(master_card_id); + database::bump_revision(); + drop(lock); + let _ = fs::remove_dir_all(card_dir(master_card_id)); + Ok(()) +} + +pub fn create_character(uid: i64, fields: &Fields) -> Result { + if !permissions::has(uid, permissions::CARD_UPLOAD) { + return Err(String::from("You do not have permission to upload characters")); + } + // Fail fast: cheap text validation before any image work + build_character(0, fields, &object!{})?; + let character_art = collect_character_art(fields, true)?; + + let lock = lock_onto_mutex!(UPLOAD_LOCK); + let master_character_id = database::next_character_id(); + if master_character_id > database::LAST_CHARACTER_ID { + return Err(String::from("The custom character id space is exhausted")); + } + if OFFICIAL_CHARACTER_IDS.contains(&master_character_id) { + return Err(format!("Character id {} already exists in masterdata", master_character_id)); + } + + let mut character = build_character(master_character_id, fields, &object!{})?; + character["art"] = merge_art(&array![], &character_art); + + write_art(&character_dir(master_character_id), &character_art)?; + database::insert_character(master_character_id, uid, &character); + database::bump_revision(); + drop(lock); + + Ok(master_character_id) +} + +pub fn update_character(uid: i64, master_character_id: i64, fields: &Fields) -> Result<(), String> { + let Some(owner) = database::get_character_owner(master_character_id) else { + return Err(String::from("Character not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You do not have permission to edit this character")); + } + let stored = database::get_character(master_character_id).ok_or(String::from("Character not found"))?; + + // Field validation first (cheap), art processing second - fail fast + let mut character = build_character(master_character_id, fields, &stored)?; + let character_art = collect_character_art(fields, false)?; + character["art"] = merge_art(&stored["art"], &character_art); + + let lock = lock_onto_mutex!(UPLOAD_LOCK); + write_art(&character_dir(master_character_id), &character_art)?; + database::update_character(master_character_id, &character); + database::bump_revision(); + drop(lock); + + Ok(()) +} + +// A character can only go once nothing references it - a dangling +// master_character_id in a served card is a client crash +pub fn delete_character(uid: i64, master_character_id: i64) -> Result<(), String> { + let Some(owner) = database::get_character_owner(master_character_id) else { + return Err(String::from("Character not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You do not have permission to delete this character")); + } + let referenced = database::cards_using_character(master_character_id); + if referenced > 0 { + return Err(format!("{} card(s) still use this character - delete them first", referenced)); + } + let lock = lock_onto_mutex!(UPLOAD_LOCK); + database::delete_character(master_character_id); + database::bump_revision(); + drop(lock); + let _ = fs::remove_dir_all(character_dir(master_character_id)); + Ok(()) +} + +async fn create(req: HttpRequest, payload: Multipart) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let fields = match read_multipart(payload).await { + Ok(fields) => fields, + Err(e) => return webui::error(&e) + }; + match create_card(uid, &fields) { + Ok(master_card_id) => send_json(object!{ + result: "OK", + master_card_id: master_card_id + }), + Err(e) => webui::error(&e) + } +} + +async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let fields = match read_multipart(payload).await { + Ok(fields) => fields, + Err(e) => return webui::error(&e) + }; + let master_card_id = field_str(&fields, "master_card_id").parse::().unwrap_or(0); + match update_card(uid, master_card_id, &fields) { + Ok(()) => send_json(object!{ + result: "OK", + master_card_id: master_card_id + }), + Err(e) => webui::error(&e) + } +} + +async fn publish(req: HttpRequest, body: String) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + let master_card_id = body["master_card_id"].as_i64().unwrap_or(0); + match set_card_flags(uid, master_card_id, body["published"].as_bool(), body["obtainable"].as_bool()) { + Ok(()) => send_json(object!{ + result: "OK" + }), + Err(e) => webui::error(&e) + } +} + +async fn delete(req: HttpRequest, body: String) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + match delete_card(uid, body["master_card_id"].as_i64().unwrap_or(0)) { + Ok(()) => send_json(object!{ + result: "OK" + }), + Err(e) => webui::error(&e) + } +} + +async fn character_create(req: HttpRequest, payload: Multipart) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let fields = match read_multipart(payload).await { + Ok(fields) => fields, + Err(e) => return webui::error(&e) + }; + match create_character(uid, &fields) { + Ok(master_character_id) => send_json(object!{ + result: "OK", + master_character_id: master_character_id + }), + Err(e) => webui::error(&e) + } +} + +async fn character_update(req: HttpRequest, payload: Multipart) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let fields = match read_multipart(payload).await { + Ok(fields) => fields, + Err(e) => return webui::error(&e) + }; + let master_character_id = field_str(&fields, "master_character_id").parse::().unwrap_or(0); + match update_character(uid, master_character_id, &fields) { + Ok(()) => send_json(object!{ + result: "OK", + master_character_id: master_character_id + }), + Err(e) => webui::error(&e) + } +} + +async fn character_delete(req: HttpRequest, body: String) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + match delete_character(uid, body["master_character_id"].as_i64().unwrap_or(0)) { + Ok(()) => send_json(object!{ + result: "OK" + }), + Err(e) => webui::error(&e) + } +} + +async fn mine(req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + send_json(object!{ + result: "OK", + cards: database::get_cards_by_owner(uid), + characters: database::get_characters_by_owner(uid) + }) +} + +// The public card browser: the published catalog with uploader names, plus +// the custom characters those cards reference. Anonymous viewers are fine - +// published means public +async fn browse(_req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let mut cards = database::get_browse_cards(); + for card in cards.members_mut() { + card["uploader"] = userdata::get_name_and_rank(card["owner_id"].as_i64().unwrap_or(0))["user_name"].clone(); + card.remove("owner_id"); + } + let characters = database::get_characters_for_cards(0, &cards); + send_json(object!{ + result: "OK", + cards: cards, + characters: characters + }) +} + +#[cfg(test)] +pub mod tests { + use super::*; + + // Distinct bytes per file, so every art entry gets its own md5 and a + // content-addressed lookup can be asserted per kind + pub fn seeded_png(width: u32, height: u32, seed: u8) -> Vec { + let mut rv = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(width, height, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, seed, 255]) + })).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap(); + rv + } + + // A navi-style source: transparent background, opaque figure - exercises + // the cutout derivation lane. Deliberately odd-sized: every derived kind + // must still come out at the exact official target size + pub fn cutout_png(width: u32, height: u32, seed: u8) -> Vec { + let mut img = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for y in height / 8..height * 7 / 8 { + for x in width / 3..width * 2 / 3 { + img.put_pixel(x, y, image::Rgba([(x % 256) as u8, (y % 256) as u8, seed, 255])); + } + } + let mut rv = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png).unwrap(); + rv + } + + pub fn field(fields: &mut Fields, key: &str, value: &str) { + fields.insert(String::from(key), value.as_bytes().to_vec()); + } + + // A complete, valid rarity-1 card upload + pub fn base_fields() -> Fields { + let mut fields = Fields::new(); + field(&mut fields, "name", "Test Card"); + field(&mut fields, "name_en", "Test Card EN"); + field(&mut fields, "master_character_id", "1001"); + field(&mut fields, "type", "1"); + field(&mut fields, "rarity", "1"); + field(&mut fields, "hp", "2"); + field(&mut fields, "smile", "1000"); + field(&mut fields, "cool", "1000"); + field(&mut fields, "pure", "1000"); + field(&mut fields, "master_skill_center_id", "100001"); + field(&mut fields, "skill_name", "Test Skill"); + field(&mut fields, "skill_name_en", "Test Skill EN"); + field(&mut fields, "skill_detail_text", "Does a thing"); + field(&mut fields, "skill_detail_text_en", "Does a thing in English"); + field(&mut fields, "skill_trigger", "3"); + field(&mut fields, "skill_trigger_value", "25/24/24"); + field(&mut fields, "skill_probability", "39000/39000/39000"); + field(&mut fields, "skill_effective_milli_secs", "2000/2000/2000"); + field(&mut fields, "skill_sub_target", "0"); + field(&mut fields, "skill_target_group_id", "0"); + field(&mut fields, "skill_target_school_grade", "0"); + field(&mut fields, "skill_effect_type", "4"); + field(&mut fields, "skill_effective_values", "124/126/128"); + // Odd-sized sources: the server derives all 7 kinds per variant + fields.insert(String::from("art_00"), cutout_png(870, 1100, 10)); + fields.insert(String::from("art_01"), seeded_png(1333, 987, 20)); + fields + } + + pub fn character_fields() -> Fields { + let mut fields = Fields::new(); + field(&mut fields, "character_name", "Test Chara"); + field(&mut fields, "character_name_en", "Test Chara EN"); + field(&mut fields, "character_name_ruby", "てすと"); + field(&mut fields, "character_name_ruby_en", "Tesuto"); + field(&mut fields, "character_detail_text", "A test character."); + field(&mut fields, "character_detail_text_en", "A test character (EN)."); + field(&mut fields, "character_name_richtext_gacha", "Test Chara"); + field(&mut fields, "character_name_richtext_gacha_en", "Test Chara"); + field(&mut fields, "character_height", ""); + field(&mut fields, "character_blood_type", ""); + field(&mut fields, "character_blood_type_en", ""); + field(&mut fields, "character_birthday", "?月?日"); + field(&mut fields, "character_birthday_en", ""); + field(&mut fields, "character_voice_actor", ""); + field(&mut fields, "character_voice_actor_en", ""); + field(&mut fields, "character_image_color", "888888"); + field(&mut fields, "character_image_color_dark", "888888"); + // Odd sizes on purpose; no icon - it derives from the portrait + fields.insert(String::from("pr"), seeded_png(431, 617, 201)); + fields.insert(String::from("sign"), seeded_png(600, 500, 202)); + fields.insert(String::from("character"), seeded_png(555, 999, 203)); + fields + } + + // Permission grants need a grantor; an owner uid is the bootstrap one. + // Owners are cleared afterwards so unrelated tests never see them + pub fn with_permissions(uid: i64, scopes: &[&str], body: impl FnOnce() -> T) -> T { + crate::runtime::update_owners(&[9_000_001]); + for scope in scopes { + permissions::grant(uid, scope, 9_000_001).unwrap(); + } + let rv = body(); + for scope in scopes { + let _ = permissions::revoke(uid, scope, 9_000_001); + } + crate::runtime::update_owners(&[]); + rv + } + + pub fn wipe(uid: i64) { + crate::runtime::update_owners(&[uid]); + for card in database::get_cards_by_owner(uid).members() { + let _ = delete_card(uid, card["master_card_id"].as_i64().unwrap()); + } + for character in database::get_characters_by_owner(uid).members() { + let _ = delete_character(uid, character["master_character_id"].as_i64().unwrap()); + } + crate::runtime::update_owners(&[]); + } + + // A full create: derived ids, pinned columns, art md5s and the catalog + // shape the client parses + #[test] + fn create_builds_the_catalog_entry() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(4001); + + let fields = base_fields(); + let id = with_permissions(4001, &[permissions::CARD_UPLOAD], || create_card(4001, &fields).unwrap()); + assert!(id >= database::FIRST_CARD_ID); + assert!(id / 10000 >= database::FIRST_ILLUST_PREFIX); + + let card = database::get_card(id).unwrap(); + assert_eq!(card["master_card_id"].as_i64(), Some(id)); + assert_eq!(card["master_skill_id"].as_i64(), Some(id)); + assert_eq!(card["master_character_id"].as_i64(), Some(1001)); + assert_eq!(card["illust_id"].to_string(), format!("{:05}_{:04}_00", id / 10000, id % 10000)); + assert_eq!(card["evolve_illust_id"].to_string(), format!("{:05}_{:04}_01", id / 10000, id % 10000)); + assert_eq!(card["master_card_level_id"].as_i64(), Some(1)); + assert_eq!(card["master_release_label_id"].as_i64(), Some(1)); + assert_eq!(card["get_category"].as_i64(), Some(GET_CATEGORY_GACHA)); + assert_eq!(card["unique_background_file_name"].as_str(), Some("")); + assert_eq!(card["skill"]["effective_values"].len(), 3); + assert_eq!(card["skill"]["effect_type"].as_i64(), Some(4)); + // All 7 kinds, both variants + assert_eq!(card["art"].len(), 14); + // A draft: owner-only, not obtainable, never resolvable by viewers + assert_eq!(card["obtainable"].as_bool(), Some(false)); + assert!(!database::is_published(id)); + assert!(!viewer_can_resolve(id, PROTOCOL_VERSION)); + + // Every art entry hashes the exact processed bytes on disk, the data + // route index resolves the md5 back to that file, and every derived + // kind landed at its exact official size despite the odd-sized source + for art in card["art"].members() { + let path = format!("{}/{}_{}.png", card_dir(id), art["kind"], art["variant"]); + let bytes = fs::read(&path).unwrap(); + assert_eq!(format!("{:x}", md5::compute(&bytes)), art["md5"].to_string()); + assert_eq!(bytes.len(), art["size"].as_usize().unwrap()); + let resolved = database::find_asset_by_md5(&art["md5"].to_string()).unwrap(); + assert_eq!(fs::read(asset_path(&resolved)).unwrap(), bytes); + let img = image::load_from_memory(&bytes).unwrap(); + let target = CARD_ART.iter().find(|k| art["kind"] == k.kind).unwrap(); + assert_eq!((img.width(), img.height()), (target.width, target.height), "kind {}", art["kind"]); + } + + // card_info synthesizes the csv shape for the runtime band + let info = card_info(id); + assert_eq!(info["rarity"].as_i64(), Some(1)); + assert_eq!(info["masterCardLevelId"].as_i64(), Some(1)); + assert_eq!(info["masterCharacterId"].as_i64(), Some(1001)); + assert_eq!(crate::router::items::get_rarity(id), 1); + // Official rows still come from the baked table + assert_eq!(card_info(10010001)["rarity"].as_i64(), Some(1)); + assert!(card_info(id + 5000).is_null()); + + with_permissions(4001, &[permissions::CARD_PUBLISH], || set_card_flags(4001, id, Some(true), Some(true)).unwrap()); + assert!(viewer_can_resolve(id, PROTOCOL_VERSION)); + // A protocol-2 viewer can't fetch the catalog, so it can't resolve it + assert!(!viewer_can_resolve(id, 2)); + assert_eq!(database::obtainable_card_ids(1), vec![id]); + + wipe(4001); + } + + // A new character via its own route: pinned numeric columns, art, and the + // reference/deletion lifecycle with a card built on it + #[test] + fn character_lifecycle() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(4002); + + let character_id = with_permissions(4002, &[permissions::CARD_UPLOAD], || create_character(4002, &character_fields()).unwrap()); + assert!(character_id >= database::FIRST_CHARACTER_ID); + assert!(!OFFICIAL_CHARACTER_IDS.contains(&character_id)); + + let character = database::get_character(character_id).unwrap(); + assert_eq!(character["category"].as_i64(), Some(6)); + assert_eq!(character["chara_category"].as_i64(), Some(1)); + assert_eq!(character["master_group_id"].as_i64(), Some(9000)); + assert_eq!(character["school_grade"].as_i64(), Some(0)); + assert_eq!(character["sprite_name"].as_str(), Some("")); + assert_eq!(character["display_order"].as_i64(), Some(character_id)); + assert_eq!(character["master_release_label_id"].as_i64(), Some(1)); + // All 4 kinds exist even though only 3 inputs were supplied: the icon + // derives from the portrait + assert_eq!(character["art"].len(), 4); + assert!(character["art"].members().any(|art| art["kind"] == "icon")); + // Numbers, not enum names - a name here is a live-start softlock + for key in ["category", "chara_category", "master_group_id", "school_grade"] { + assert!(character[key].as_i64().is_some(), "{} is not numeric", key); + } + // Processed bytes are what's hashed, and every odd-sized input landed + // at its exact official size + for art in character["art"].members() { + let path = format!("{}/{}.png", character_dir(character_id), art["kind"]); + let bytes = fs::read(&path).unwrap(); + assert_eq!(format!("{:x}", md5::compute(&bytes)), art["md5"].to_string()); + let img = image::load_from_memory(&bytes).unwrap(); + let target = CHARACTER_ART.iter().find(|k| art["kind"] == k.kind).unwrap(); + assert_eq!((img.width(), img.height()), (target.width, target.height), "kind {}", art["kind"]); + } + + // An explicit icon override beats the derived one + let derived_icon = character["art"].members().find(|art| art["kind"] == "icon").unwrap()["md5"].to_string(); + let mut edit = Fields::new(); + edit.insert(String::from("icon"), seeded_png(300, 300, 210)); + with_permissions(4002, &[permissions::CARD_UPLOAD], || update_character(4002, character_id, &edit).unwrap()); + let updated = database::get_character(character_id).unwrap(); + let new_icon = updated["art"].members().find(|art| art["kind"] == "icon").unwrap()["md5"].to_string(); + assert_ne!(new_icon, derived_icon); + let img = image::load_from_memory(&fs::read(format!("{}/icon.png", character_dir(character_id))).unwrap()).unwrap(); + assert_eq!((img.width(), img.height()), (230, 230)); + + // A card can reference the uploader's own character; the character + // then can't be deleted until the card goes + let mut fields = base_fields(); + field(&mut fields, "master_character_id", &character_id.to_string()); + let card_id = with_permissions(4002, &[permissions::CARD_UPLOAD], || create_card(4002, &fields).unwrap()); + let err = with_permissions(4002, &[permissions::CARD_UPLOAD], || delete_character(4002, character_id)).unwrap_err(); + assert!(err.contains("still use this character"), "{}", err); + with_permissions(4002, &[permissions::CARD_UPLOAD], || delete_card(4002, card_id).unwrap()); + with_permissions(4002, &[permissions::CARD_UPLOAD], || delete_character(4002, character_id).unwrap()); + assert!(!database::has_character(character_id)); + assert!(fs::read_dir(character_dir(character_id)).is_err()); + + // Another user can't reference a draft-only character of somebody else + let other = character_fields(); + let other_id = with_permissions(4002, &[permissions::CARD_UPLOAD], || create_character(4002, &other).unwrap()); + let mut fields = base_fields(); + field(&mut fields, "master_character_id", &other_id.to_string()); + let err = with_permissions(4003, &[permissions::CARD_UPLOAD], || create_card(4003, &fields)).unwrap_err(); + assert!(err.contains("Unknown master_character_id"), "{}", err); + + wipe(4002); + wipe(4003); + } + + #[test] + fn every_validation_rejection() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(4004); + + let run = |fields: &Fields| with_permissions(4004, &[permissions::CARD_UPLOAD], || create_card(4004, fields)); + let mutated = |key: &str, value: &str| { + let mut fields = base_fields(); + field(&mut fields, key, value); + fields + }; + + // Referential integrity + assert!(run(&mutated("master_character_id", "999999")).unwrap_err().contains("Unknown master_character_id")); + assert!(run(&mutated("master_character_id", "0")).unwrap_err().contains("Unknown master_character_id")); + assert!(run(&mutated("master_skill_center_id", "0")).unwrap_err().contains("master_skill_center_id")); + assert!(run(&mutated("master_skill_center_id", "424242")).unwrap_err().contains("master_skill_center_id")); + assert!(run(&mutated("name", "")).unwrap_err().contains("Card name is required")); + assert!(run(&mutated("name_en", "")).unwrap_err().contains("Card English name is required")); + assert!(run(&mutated("skill_name", "")).unwrap_err().contains("Skill name is required")); + assert!(run(&mutated("skill_detail_text_en", "")).unwrap_err().contains("Skill English description is required")); + assert!(run(&mutated("skill_target_group_id", "123")).unwrap_err().contains("skill_target_group_id")); + + // Enum ranges - each one is a client crash, not a cosmetic error - + // and every message states the actual allowed range + assert!(run(&mutated("type", "0")).unwrap_err().contains("type must be 1-4")); + assert!(run(&mutated("type", "5")).unwrap_err().contains("type must be 1-4")); + assert!(run(&mutated("rarity", "0")).unwrap_err().contains("rarity must be 1-3")); + assert!(run(&mutated("rarity", "4")).unwrap_err().contains("rarity must be 1-3")); + assert!(run(&mutated("skill_trigger", "5")).unwrap_err().contains("skill_trigger must be 1-4")); + assert!(run(&mutated("skill_trigger", "0")).unwrap_err().contains("skill_trigger must be 1-4")); + assert!(run(&mutated("skill_effect_type", "12")).unwrap_err().contains("skill_effect_type must be 1-11")); + assert!(run(&mutated("skill_effect_type", "0")).unwrap_err().contains("skill_effect_type must be 1-11")); + assert!(run(&mutated("skill_sub_target", "2")).unwrap_err().contains("skill_sub_target must be 0 or 1")); + assert!(run(&mutated("skill_target_school_grade", "4")).unwrap_err().contains("skill_target_school_grade must be 0-3")); + + // Skill array shapes: rarity 1 needs exactly 3 level entries; a lone + // effective_milli_secs is the one shipped exception + assert!(run(&mutated("skill_effective_values", "124/126")).unwrap_err().contains("skill_effective_values")); + assert!(run(&mutated("skill_probability", "1/2/3/4")).unwrap_err().contains("skill_probability")); + assert!(run(&mutated("skill_effective_values", "1/2/-3")).unwrap_err().contains("uint")); + assert!(run(&mutated("skill_effective_values", "1/2/x")).unwrap_err().contains("not a number")); + assert!(run(&mutated("skill_trigger_value", "")).unwrap_err().contains("skill_trigger_value")); + assert!(run(&mutated("skill_effective_milli_secs", "2000")).is_ok()); + assert!(run(&mutated("skill_probability", "2000000/1/1")).unwrap_err().contains("maximum")); + // Rarity 2 wants 5 entries, so the rarity-1 arrays no longer fit + assert!(run(&mutated("rarity", "2")).unwrap_err().contains("needs exactly 5")); + + // Stat bounds, computed from the official card.csv: hp really is a + // tiny per-rarity constant (R 2 / SR 3 / UR 4), so an R card allows + // 1-2 - and the message says so instead of hiding the limit + let caps = STAT_CAPS.get(&1).copied().unwrap(); + assert_eq!(caps.0, 2, "official R hp cap"); + assert!(caps.1 > 0); + let smile_err = run(&mutated("smile", &(caps.1 + 1).to_string())).unwrap_err(); + assert!(smile_err.contains(&format!("smile must be between 1 and {} for a R card", caps.1)), "{}", smile_err); + let hp_err = run(&mutated("hp", "3")).unwrap_err(); + assert!(hp_err.contains("hp must be between 1 and 2 for a R card"), "{}", hp_err); + assert!(run(&mutated("hp", "0")).unwrap_err().contains("hp must be between 1 and 2")); + assert!(run(&mutated("hp", "-1")).unwrap_err().contains("hp must be between 1 and 2")); + + // Fail fast: a field error rejects BEFORE any art is decoded - the + // garbage art bytes never get the chance to produce their own error + let mut fields = base_fields(); + field(&mut fields, "rarity", "9"); + fields.insert(String::from("art_00"), b"garbage that would fail decoding".to_vec()); + assert!(run(&fields).unwrap_err().contains("rarity must be 1-3")); + + // Art: both source artworks are required on create; garbage and + // absurdly small inputs are refused with clear errors + let mut fields = base_fields(); + fields.remove("art_01"); + assert!(run(&fields).unwrap_err().contains("'art_01' (evolved card artwork) is required")); + let mut fields = base_fields(); + fields.insert(String::from("art_00"), b"not an image at all".to_vec()); + assert!(run(&fields).unwrap_err().contains("not a decodable image")); + let mut fields = base_fields(); + fields.insert(String::from("art_00"), seeded_png(32, 32, 7)); + assert!(run(&fields).unwrap_err().contains("at least")); + // The per-file cap rejects before the decoder ever sees the bytes + let mut fields = base_fields(); + fields.insert(String::from("art_00"), vec![0u8; MAX_FILE_BYTES + 1]); + assert!(run(&fields).unwrap_err().contains("per-file limit")); + + // A wrong-sized per-kind override is cropped to target, never rejected + let mut fields = base_fields(); + fields.insert(String::from("sc_00"), seeded_png(640, 640, 8)); + let override_id = run(&fields).unwrap(); + let sc = image::load_from_memory(&fs::read(format!("{}/sc_00.png", card_dir(override_id))).unwrap()).unwrap(); + assert_eq!((sc.width(), sc.height()), (1024, 512)); + + // Character validation via the character route + let runc = |fields: &Fields| with_permissions(4004, &[permissions::CARD_UPLOAD], || create_character(4004, fields)); + let mut fields = character_fields(); + fields.remove("sign"); + assert!(runc(&fields).unwrap_err().contains("'sign' art is required")); + let mut fields = character_fields(); + fields.remove("character"); + assert!(runc(&fields).unwrap_err().contains("'character' art is required")); + let mut fields = character_fields(); + field(&mut fields, "character_name_ruby_en", ""); + assert!(runc(&fields).unwrap_err().contains("English name reading is required")); + let mut fields = character_fields(); + field(&mut fields, "character_image_color", "red"); + assert!(runc(&fields).unwrap_err().contains("hex color")); + let mut fields = character_fields(); + fields.insert(String::from("pr"), seeded_png(32, 32, 9)); + assert!(runc(&fields).unwrap_err().contains("at least")); + + // Only the two deliberate successes above wrote rows + assert_eq!(database::card_count_for_owner(4004), 2); + wipe(4004); + } + + #[test] + fn permission_gates() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(4005); + wipe(4006); + + let fields = base_fields(); + // No scopes at all + assert!(create_card(4005, &fields).unwrap_err().contains("permission to upload")); + assert!(create_character(4005, &character_fields()).unwrap_err().contains("permission to upload")); + + let id = with_permissions(4005, &[permissions::CARD_UPLOAD], || create_card(4005, &fields).unwrap()); + + // card.upload edits/deletes its OWN cards but cannot publish + assert!(with_permissions(4005, &[permissions::CARD_UPLOAD], || set_card_flags(4005, id, Some(true), None)).unwrap_err().contains("permission to publish")); + let mut published_fields = base_fields(); + field(&mut published_fields, "published", "1"); + assert!(with_permissions(4005, &[permissions::CARD_UPLOAD], || create_card(4005, &published_fields)).unwrap_err().contains("permission to publish")); + let published_id = with_permissions(4005, &[permissions::CARD_UPLOAD, permissions::CARD_PUBLISH], || create_card(4005, &published_fields).unwrap()); + assert!(database::is_published(published_id)); + + // A stranger with card.upload/card.publish can't touch someone else's + let mut edit = Fields::new(); + field(&mut edit, "name", "Hijacked"); + assert!(with_permissions(4006, &[permissions::CARD_UPLOAD], || update_card(4006, id, &edit)).unwrap_err().contains("permission to edit")); + assert!(with_permissions(4006, &[permissions::CARD_UPLOAD], || delete_card(4006, id)).unwrap_err().contains("permission to delete")); + assert!(with_permissions(4006, &[permissions::CARD_PUBLISH], || set_card_flags(4006, id, Some(true), None)).unwrap_err().contains("permission to publish")); + + // card.edit is moderation: manage ANY card + with_permissions(4006, &[permissions::CARD_EDIT], || { + update_card(4006, id, &edit).unwrap(); + set_card_flags(4006, id, Some(true), Some(true)).unwrap(); + set_card_flags(4006, id, Some(false), Some(false)).unwrap(); + }); + assert_eq!(database::get_card(id).unwrap()["name"].to_string(), "Hijacked"); + with_permissions(4006, &[permissions::CARD_EDIT], || delete_card(4006, id).unwrap()); + assert!(database::get_card(id).is_none()); + + wipe(4005); + wipe(4006); + } + + // Present fields replace, absent fields keep, the id never moves, and a + // replaced art file self-heals its md5 + #[test] + fn update_edits_in_place() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(4007); + + let fields = base_fields(); + let id = with_permissions(4007, &[permissions::CARD_UPLOAD], || create_card(4007, &fields).unwrap()); + let before = database::get_card(id).unwrap(); + + // The sc_00 override is deliberately the wrong size: an update crops + // it to target just like create does + let mut edit = Fields::new(); + field(&mut edit, "name", "Renamed"); + field(&mut edit, "skill_effect_type", "7"); + edit.insert(String::from("sc_00"), seeded_png(700, 900, 99)); + with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap()); + + let after = database::get_card(id).unwrap(); + assert_eq!(after["master_card_id"].as_i64(), Some(id)); + assert_eq!(after["name"].to_string(), String::from("Renamed")); + assert_eq!(after["skill"]["effect_type"].as_i64(), Some(7)); + assert_eq!(after["skill"]["name"], before["skill"]["name"]); + assert_eq!(after["smile"], before["smile"]); + assert_eq!(after["illust_id"], before["illust_id"]); + assert_eq!(after["art"].len(), 14); + + let old = before["art"].members().find(|art| art["kind"] == "sc" && art["variant"] == "00").unwrap()["md5"].to_string(); + let new = after["art"].members().find(|art| art["kind"] == "sc" && art["variant"] == "00").unwrap()["md5"].to_string(); + assert_ne!(old, new); + assert_eq!(database::find_asset_by_md5(&old), None); + assert_eq!(database::find_asset_by_md5(&new), Some(format!("{}/sc_00.png", id))); + let img = image::load_from_memory(&fs::read(format!("{}/sc_00.png", card_dir(id))).unwrap()).unwrap(); + assert_eq!((img.width(), img.height()), (1024, 512)); + + // Re-supplying a source artwork re-derives its whole variant + let old_c01 = after["art"].members().find(|art| art["kind"] == "c" && art["variant"] == "01").unwrap()["md5"].to_string(); + let mut edit = Fields::new(); + edit.insert(String::from("art_01"), cutout_png(600, 900, 123)); + with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &edit).unwrap()); + let rederived = database::get_card(id).unwrap(); + assert_eq!(rederived["art"].len(), 14); + let new_c01 = rederived["art"].members().find(|art| art["kind"] == "c" && art["variant"] == "01").unwrap()["md5"].to_string(); + assert_ne!(new_c01, old_c01); + + // An edit that would break a range is rejected and nothing is written + let mut bad = Fields::new(); + field(&mut bad, "skill_trigger", "9"); + assert!(with_permissions(4007, &[permissions::CARD_UPLOAD], || update_card(4007, id, &bad)).is_err()); + assert_eq!(database::get_card(id).unwrap()["skill"]["trigger"], after["skill"]["trigger"]); + + wipe(4007); + } + + // The runtime band must never reach a client that can't fetch the catalog + #[test] + fn strip_removes_unsupported_cards_and_their_references() { + let _lock = crate::runtime::lock_test_data_path(); + let custom = database::FIRST_CARD_ID; + let mut user = object!{ + "user": { + "favorite_master_card_id": custom, + "guest_smile_master_card_id": 10010001 + }, + "card_list": [ + { "master_card_id": 10010001 }, + { "master_card_id": 100010001 }, + { "master_card_id": custom } + ], + "deck_list": [ + { "main_card_ids": [custom, 10010001, 0] } + ] + }; + strip_unsupported(&mut user); + assert_eq!(user["card_list"].len(), 2); + // The imported band stays: those rows are baked into masterdata + assert!(user["card_list"].members().any(|card| card["master_card_id"] == 100010001)); + assert!(!user["card_list"].members().any(|card| card["master_card_id"] == custom)); + assert_eq!(user["deck_list"][0]["main_card_ids"][0].as_i64(), Some(0)); + assert_eq!(user["deck_list"][0]["main_card_ids"][1].as_i64(), Some(10010001)); + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(0)); + assert_eq!(user["user"]["guest_smile_master_card_id"].as_i64(), Some(10010001)); + } + + #[test] + fn bands_do_not_overlap() { + assert!(is_custom_runtime(database::FIRST_CARD_ID)); + assert!(!is_custom_runtime(141_720_001)); + assert!(crate::router::card::is_custom(141_720_001)); + assert!(!crate::router::card::is_custom(10_010_001)); + assert!(viewer_can_resolve(10_010_001, 0)); + assert!(!viewer_can_resolve(100_010_001, 1)); + assert!(viewer_can_resolve(100_010_001, 2)); + } +} diff --git a/src/router/custom_card/art.rs b/src/router/custom_card/art.rs new file mode 100644 index 0000000..33c3299 --- /dev/null +++ b/src/router/custom_card/art.rs @@ -0,0 +1,321 @@ +// Art derivation for custom cards: every in-game kind is generated from one +// source artwork per variant, replicating the SIF1-import pipeline that built +// the 7 kinds for all 4,002 imported cards +// (sif1-cards/pipeline/07_prototype.py + 08_convert_all.py + 15_memoria_art.py +// + 17_skillcutin_art.py + 18_blank_art_fallback.py). Uploads are never +// rejected for dimensions: sources and per-kind overrides alike are +// center-crop-covered and Lanczos3-resampled to the exact target sizes, and +// the stored/hashed bytes are always the processed PNG output. +// +// Two source treatments, matching the pipeline's two art classes: +// * a TRANSPARENT cutout (SIF1 "navi" standee art): the figure (alpha bbox) +// is placed on the official c_/h_ canvas geometry (fit 1700x1180, centred +// on 2048x1260) over a blurred+darkened backdrop made from the art itself; +// m_/sc_/p_/r_ are the pipeline's figure-proportional crops. +// * OPAQUE artwork: landscape art covers the c_ canvas directly; portrait art +// gets the blurred-backdrop presentation with the whole image centred. +// h_ falls back to the c_ content and m_ to its top square, exactly the +// fallback semantics 18_blank_art_fallback.py shipped for the 74 SIF1 +// cards with no transparent standee. + +use image::{imageops, DynamicImage, Rgba, RgbaImage}; + +// Permissive sanity floor: upscaling small-ish sources is allowed, only +// absurd inputs are refused +pub const MIN_SOURCE_DIM: u32 = 100; + +// c_/h_ canvas and the navi fit box (make_full_illust / place_navi) +const CANVAS_W: u32 = 2048; +const CANVAS_H: u32 = 1260; +const FIT_W: f64 = 1700.0; +const FIT_H: f64 = 1180.0; +const BRIGHTNESS: f64 = 0.88; +// GaussianBlur(24) @ 2048x1260 == GaussianBlur(6) @ 512x315 upscaled (the +// pipeline used the same half-res trick for speed) +const BLUR_W: u32 = 512; +const BLUR_H: u32 = 315; +const BLUR_SIGMA: f32 = 6.0; + +// m_: head-anchored square of the placed figure (15_memoria_art.py, fitted +// against official pairs at alpha-IoU 0.89-0.94) +const M_SIDE: f64 = 0.78; // of figure height +const M_TOP: f64 = 0.03; // top edge this far ABOVE the figure top + +// sc_: bust crop (17_skillcutin_art.py, fitted at alpha-IoU 0.84-0.92) +const SC_CROP_H: f64 = 0.68; // of figure height +const SC_CROP_TOP: f64 = -0.055; // of figure height, relative to figure top + +pub fn decode_source(name: &str, bytes: &[u8]) -> Result { + let img = image::load_from_memory(bytes) + .map_err(|_| format!("'{}' is not a decodable image (png, jpg and webp work)", name))?; + if img.width() < MIN_SOURCE_DIM || img.height() < MIN_SOURCE_DIM { + return Err(format!("'{}' is only {}x{} - at least {}x{} is required", name, img.width(), img.height(), MIN_SOURCE_DIM, MIN_SOURCE_DIM)); + } + Ok(img) +} + +pub fn encode_png(img: &RgbaImage) -> Result, String> { + let mut rv = Vec::new(); + DynamicImage::ImageRgba8(img.clone()) + .write_to(&mut std::io::Cursor::new(&mut rv), image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + Ok(rv) +} + +fn resize(img: &RgbaImage, w: u32, h: u32) -> RgbaImage { + imageops::resize(img, w.max(1), h.max(1), imageops::FilterType::Lanczos3) +} + +// Scale-to-cover + centre-crop: the aspect-mismatch treatment for every +// override and for opaque landscape sources (the pipeline's cover()) +pub fn cover(img: &RgbaImage, tw: u32, th: u32) -> RgbaImage { + let scale = f64::max(tw as f64 / img.width() as f64, th as f64 / img.height() as f64); + let scaled = resize(img, (img.width() as f64 * scale).round() as u32, (img.height() as f64 * scale).round() as u32); + let x = (scaled.width() - tw.min(scaled.width())) / 2; + let y = (scaled.height() - th.min(scaled.height())) / 2; + imageops::crop_imm(&scaled, x, y, tw, th).to_image() +} + +// The bounding box of the non-transparent pixels +fn alpha_bbox(img: &RgbaImage) -> Option<(u32, u32, u32, u32)> { + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32); + for (x, y, px) in img.enumerate_pixels() { + if px[3] > 0 { + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + if x0 == u32::MAX { + None + } else { + Some((x0, y0, x1 + 1, y1 + 1)) + } +} + +// A source counts as a transparent cutout (SIF1 navi-style standee) when a +// meaningful share of it is fully transparent background +fn is_cutout(img: &RgbaImage) -> bool { + let total = (img.width() as u64) * (img.height() as u64); + let transparent = img.pixels().filter(|px| px[3] < 16).count() as u64; + transparent * 100 / total.max(1) >= 5 +} + +// Multiplicative darken (PIL ImageEnhance.Brightness) +fn darken(img: &mut RgbaImage, factor: f64) { + for px in img.pixels_mut() { + for c in 0..3 { + px[c] = (px[c] as f64 * factor).round().min(255.0) as u8; + } + } +} + +// Crop that pads with transparency outside the source (PIL crop() semantics), +// so figure-proportional crops can reach past the figure +fn crop_pad(src: &RgbaImage, x0: i64, y0: i64, w: u32, h: u32) -> RgbaImage { + let mut canvas = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 0])); + let sx = x0.max(0) as u32; + let sy = y0.max(0) as u32; + if sx < src.width() && sy < src.height() { + let cw = (src.width() - sx).min((x0 + w as i64 - sx as i64).max(0) as u32); + let ch = (src.height() - sy).min((y0 + h as i64 - sy as i64).max(0) as u32); + if cw > 0 && ch > 0 { + let piece = imageops::crop_imm(src, sx, sy, cw, ch).to_image(); + imageops::overlay(&mut canvas, &piece, (sx as i64 - x0).max(0), (sy as i64 - y0).max(0)); + } + } + canvas +} + +// The blurred + darkened backdrop the c_ presentation puts behind portrait / +// cutout art, made from the art itself (the import used the SIF1 card +// background here; a runtime upload has no separate background, and the +// art-as-its-own-backdrop is the same trick the custom-song jacket blur uses) +fn blurred_backdrop(source: &RgbaImage) -> RgbaImage { + let mut flat = flatten(source); + flat = cover(&flat, BLUR_W, BLUR_H); + flat = imageops::blur(&flat, BLUR_SIGMA); + darken(&mut flat, BRIGHTNESS); + resize(&flat, CANVAS_W, CANVAS_H) +} + +// Flatten transparency onto the mean colour of the opaque pixels (the +// pipeline flattened onto the tile's centre colour; a cutout's centre may be +// transparent, so the figure's own average is the stable equivalent) +fn flatten(img: &RgbaImage) -> RgbaImage { + let (mut r, mut g, mut b, mut n) = (0u64, 0u64, 0u64, 0u64); + for px in img.pixels() { + if px[3] >= 128 { + r += px[0] as u64; + g += px[1] as u64; + b += px[2] as u64; + n += 1; + } + } + let base = if n > 0 { + Rgba([(r / n) as u8, (g / n) as u8, (b / n) as u8, 255]) + } else { + Rgba([240, 240, 240, 255]) + }; + let mut canvas = RgbaImage::from_pixel(img.width(), img.height(), base); + imageops::overlay(&mut canvas, img, 0, 0); + canvas +} + +// place_navi: the figure scaled into the 1700x1180 fit box, and its placement +// on the 2048x1260 canvas +fn place_figure(figure: &RgbaImage) -> (RgbaImage, i64, i64) { + let scale = f64::min(FIT_H / figure.height() as f64, FIT_W / figure.width() as f64); + let placed = resize(figure, (figure.width() as f64 * scale).round() as u32, (figure.height() as f64 * scale).round() as u32); + let x = (CANVAS_W as i64 - placed.width() as i64) / 2; + let y = (CANVAS_H as i64 - placed.height() as i64) / 2; + (placed, x, y) +} + +// p_: the 136x508 vertical strip through the figure's centre column +fn portrait_strip(figure: &RgbaImage) -> RgbaImage { + let scaled = resize(figure, ((figure.width() as f64 * 508.0 / figure.height() as f64).round() as u32).max(1), 508); + let cx = scaled.width() as i64 / 2; + crop_pad(&scaled, cx - 68, 0, 136, 508) +} + +// m_/r_: head-anchored square of the figure (side 0.78 * height, top edge +// 0.03 * height above the figure) +fn head_square(figure: &RgbaImage) -> RgbaImage { + let h = figure.height() as f64; + let side = (h * M_SIDE).round().max(1.0) as u32; + let ox = (figure.width() as i64 - side as i64) / 2; + let oy = -(h * M_TOP).round() as i64; + crop_pad(figure, ox, oy, side, side) +} + +// sc_: the 2:1 bust crop, bottom edge running off the figure +fn bust_crop(figure: &RgbaImage) -> RgbaImage { + let fh = figure.height() as f64; + let ch = (fh * SC_CROP_H).round().max(1.0) as u32; + let cw = ch * 2; + let x0 = figure.width() as i64 / 2 - cw as i64 / 2; + let y0 = (fh * SC_CROP_TOP).round() as i64; + resize(&crop_pad(figure, x0, y0, cw, ch), 1024, 512) +} + +// All 7 card kinds from one source artwork. Every output is at the exact +// official size; the caller overlays any explicit per-kind overrides +pub fn derive_card_art(source: &DynamicImage) -> Vec<(&'static str, RgbaImage)> { + let rgba = source.to_rgba8(); + let cutout = is_cutout(&rgba).then(|| alpha_bbox(&rgba)).flatten(); + + let (c, h, figure) = if let Some((x0, y0, x1, y1)) = cutout { + // Transparent standee: the official composition + let figure = imageops::crop_imm(&rgba, x0, y0, x1 - x0, y1 - y0).to_image(); + let (placed, px, py) = place_figure(&figure); + let mut c = blurred_backdrop(&rgba); + imageops::overlay(&mut c, &placed, px, py); + let mut h = RgbaImage::from_pixel(CANVAS_W, CANVAS_H, Rgba([0, 0, 0, 0])); + imageops::overlay(&mut h, &placed, px, py); + (c, h, figure) + } else { + // Opaque artwork: landscape covers the canvas, portrait gets the + // blurred-backdrop presentation; h_ falls back to the c_ content + let c = if rgba.height() > rgba.width() { + let (placed, px, py) = place_figure(&rgba); + let mut c = blurred_backdrop(&rgba); + imageops::overlay(&mut c, &placed, px, py); + c + } else { + cover(&rgba, CANVAS_W, CANVAS_H) + }; + (c.clone(), c, rgba) + }; + + let t = resize(&c, 512, 315); + let m = resize(&head_square(&figure), 380, 380); + let r = resize(&head_square(&figure), 256, 256); + let sc = bust_crop(&figure); + let p = portrait_strip(&figure); + + vec![("c", c), ("h", h), ("t", t), ("p", p), ("r", r), ("m", m), ("sc", sc)] +} + +// Character icon derived from the portrait when no explicit icon is supplied: +// a top-anchored square for portrait sources (the face is up there), centred +// for landscape ones +pub fn derive_character_icon(portrait: &DynamicImage) -> RgbaImage { + let rgba = portrait.to_rgba8(); + let side = rgba.width().min(rgba.height()); + let x0 = (rgba.width() - side) / 2; + let y0 = if rgba.height() > rgba.width() { 0 } else { (rgba.height() - side) / 2 }; + resize(&imageops::crop_imm(&rgba, x0, y0, side, side).to_image(), 230, 230) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cutout_source(w: u32, h: u32) -> DynamicImage { + // A transparent canvas with an opaque figure occupying the middle band + let mut img = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 0])); + for y in h / 8..h * 7 / 8 { + for x in w / 3..w * 2 / 3 { + img.put_pixel(x, y, Rgba([200, 60, 120, 255])); + } + } + DynamicImage::ImageRgba8(img) + } + + fn opaque_source(w: u32, h: u32) -> DynamicImage { + DynamicImage::ImageRgba8(RgbaImage::from_fn(w, h, |x, y| { + Rgba([(x % 256) as u8, (y % 256) as u8, 99, 255]) + })) + } + + const TARGETS: &[(&str, u32, u32)] = &[ + ("c", 2048, 1260), ("h", 2048, 1260), ("t", 512, 315), ("p", 136, 508), + ("r", 256, 256), ("m", 380, 380), ("sc", 1024, 512) + ]; + + // Odd-sized sources in -> every kind at its exact official size out, + // through both the cutout and the opaque path, portrait and landscape + #[test] + fn derivation_hits_exact_target_dims() { + for source in [cutout_source(437, 1013), opaque_source(437, 1013), opaque_source(1900, 700), opaque_source(300, 300)] { + let derived = derive_card_art(&source); + assert_eq!(derived.len(), 7); + for (kind, img) in derived { + let target = TARGETS.iter().find(|(k, _, _)| *k == kind).unwrap(); + assert_eq!((img.width(), img.height()), (target.1, target.2), "kind {}", kind); + } + } + let icon = derive_character_icon(&opaque_source(431, 617)); + assert_eq!((icon.width(), icon.height()), (230, 230)); + } + + // A transparent standee gets a transparent h_ aligned with c_; an opaque + // source falls back to h_ == c_ (18_blank_art_fallback semantics) + #[test] + fn cutouts_and_opaque_sources_take_their_own_lanes() { + let derived = derive_card_art(&cutout_source(800, 1000)); + let h = &derived.iter().find(|(k, _)| *k == "h").unwrap().1; + assert!(h.pixels().any(|px| px[3] == 0), "cutout h_ keeps transparency"); + let c = &derived.iter().find(|(k, _)| *k == "c").unwrap().1; + assert!(c.pixels().all(|px| px[3] == 255), "c_ backdrop is opaque"); + + let derived = derive_card_art(&opaque_source(800, 1000)); + let h = &derived.iter().find(|(k, _)| *k == "h").unwrap().1; + let c = &derived.iter().find(|(k, _)| *k == "c").unwrap().1; + assert_eq!(h.as_raw(), c.as_raw(), "opaque h_ falls back to c_"); + } + + #[test] + fn cover_crops_to_aspect_and_floor_rejects_tiny_sources() { + let img = opaque_source(1000, 400).to_rgba8(); + let out = cover(&img, 512, 315); + assert_eq!((out.width(), out.height()), (512, 315)); + + let mut tiny = Vec::new(); + opaque_source(32, 32).write_to(&mut std::io::Cursor::new(&mut tiny), image::ImageFormat::Png).unwrap(); + assert!(decode_source("art_00", &tiny).unwrap_err().contains("at least")); + assert!(decode_source("art_00", b"garbage").unwrap_err().contains("not a decodable image")); + } +} diff --git a/src/router/event.rs b/src/router/event.rs index 4197207..d941dcf 100644 --- a/src/router/event.rs +++ b/src/router/event.rs @@ -223,7 +223,7 @@ fn get_rank(event: u32, user_id: u64) -> u32 { } async fn ranking(req: HttpRequest, Body(body): Body) -> impl Responder { - let custom_cards = crate::router::card::client_supports_custom_cards(&req); + let protocol = crate::router::global::client_protocol_version(&req); let master_event_id = body["master_event_id"].as_u32().unwrap(); let scores = crate::router::event_ranking::get_scores_json().await[master_event_id as usize].clone(); let mut rv = array![]; @@ -232,9 +232,7 @@ async fn ranking(req: HttpRequest, Body(body): Body) -> impl Responder { for score in scores.members() { if i >= start && start + body["count"].as_u32().unwrap() >= i { let mut entry = score.clone(); - if !custom_cards { - crate::router::tools::guest::proxy_user_cards(&mut entry["user_detail"]); - } + crate::router::tools::guest::proxy_user_cards(&mut entry["user_detail"], protocol); rv.push(entry).unwrap(); i += 1; } diff --git a/src/router/event_ranking.rs b/src/router/event_ranking.rs index b3d6660..82a2839 100644 --- a/src/router/event_ranking.rs +++ b/src/router/event_ranking.rs @@ -82,7 +82,9 @@ fn get_json() -> JsonValue { let mut i = 1; for score in scores.members() { - let user = guest::get_user(score["user"].as_i64().unwrap(), &object![], guest::UserView::Ranking, true); + // The cached ranking json keeps every card unproxied; event.rs + // re-proxies per requesting client's protocol version + let user = guest::get_user(score["user"].as_i64().unwrap(), &object![], guest::UserView::Ranking, u32::MAX); rv[event.to_string()].push(object!{ "rank": i, "user_detail": user, diff --git a/src/router/friend.rs b/src/router/friend.rs index 48e3329..e5ec065 100644 --- a/src/router/friend.rs +++ b/src/router/friend.rs @@ -21,7 +21,7 @@ pub fn routes(cfg: &mut web::ServiceConfig) { } async fn friend(req: HttpRequest, Session { key, body }: Session) -> impl Responder { - let custom_cards = crate::router::card::client_supports_custom_cards(&req); + let protocol = crate::router::global::client_protocol_version(&req); let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); let friends = userdata::get_acc_friends(&key); @@ -38,7 +38,7 @@ async fn friend(req: HttpRequest, Session { key, body }: Session) -> impl Respon }; for uid in rv_data.members() { - let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards); + let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, protocol); user["user"]["last_login_time"] = global::set_time(user["user"]["last_login_time"].as_u64().unwrap_or(0), user_id, false).into(); rv.push(user).unwrap(); } @@ -55,7 +55,7 @@ async fn ids(Login(key): Login) -> impl Responder { } async fn recommend(req: HttpRequest, Login(key): Login) -> impl Responder { - let custom_cards = crate::router::card::client_supports_custom_cards(&req); + let protocol = crate::router::global::client_protocol_version(&req); let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); let friends = userdata::get_acc_friends(&key); @@ -67,7 +67,7 @@ async fn recommend(req: HttpRequest, Login(key): Login) -> impl Responder { let mut rv = array![]; for uid in random.members() { - let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards); + let mut user = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, protocol); if user["user"]["friend_request_disabled"] == 1 || user.is_empty() { continue; } @@ -84,7 +84,7 @@ async fn search(req: HttpRequest, Session { key, body }: Session) -> impl Respon let friends = userdata::get_acc_friends(&key); let uid = body["user_id"].as_i64().unwrap(); - let user = guest::get_user(uid, &friends, guest::UserView::Detail, crate::router::card::client_supports_custom_cards(&req)); + let user = guest::get_user(uid, &friends, guest::UserView::Detail, crate::router::global::client_protocol_version(&req)); Api(Some(if user.is_empty() { array![] diff --git a/src/router/items.rs b/src/router/items.rs index 0c0a706..92c9c56 100644 --- a/src/router/items.rs +++ b/src/router/items.rs @@ -300,8 +300,10 @@ pub fn lp_modification(user: &mut JsonValue, change_amount: u64, remove: bool) - stamina != orig_stamina || anchor != orig_anchor } +// Falls through to the custom-card db for the runtime band (card_info), so +// duplicate conversion / evolve costs / give_character work on those too pub fn get_rarity(id: i64) -> i32 { - databases::CARD_LIST[id.to_string()]["rarity"].as_i32().unwrap_or(0) + crate::router::custom_card::card_info(id)["rarity"].as_i32().unwrap_or(0) } // true - added diff --git a/src/router/live.rs b/src/router/live.rs index de36936..b10cc86 100644 --- a/src/router/live.rs +++ b/src/router/live.rs @@ -81,7 +81,7 @@ fn random_number(lowest: usize, highest: usize) -> usize { } async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder { - let custom_cards = crate::router::card::client_supports_custom_cards(&req); + let protocol = crate::router::global::client_protocol_version(&req); let user_id = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); let friends = userdata::get_acc_friends(&key); let user = userdata::get_acc(&key); @@ -136,7 +136,7 @@ async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder { }).unwrap(); } else { if !friends["friend_user_id_list"].is_empty() { - guest_list.push(guest::get_user(friends["friend_user_id_list"][random_number(0, friends["friend_user_id_list"].len() - 1)].as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards)).unwrap(); + guest_list.push(guest::get_user(friends["friend_user_id_list"][random_number(0, friends["friend_user_id_list"].len() - 1)].as_i64().unwrap(), &friends, guest::UserView::Card, protocol)).unwrap(); } let expected: usize = 5; if guest_list.len() < expected { @@ -147,7 +147,7 @@ async fn guest(req: HttpRequest, Login(key): Login) -> impl Responder { } for uid in random.members() { - let guest = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, custom_cards); + let guest = guest::get_user(uid.as_i64().unwrap(), &friends, guest::UserView::Card, protocol); if guest["user"]["friend_request_disabled"] == 1 || guest.is_empty() { continue; } @@ -514,7 +514,9 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis Some(x) => x, None => continue }; - let character = match databases::CARD_LIST[mcid.to_string()]["masterCharacterId"].as_i64() { + // card_info falls through to the custom-card db, so a runtime card in + // the deck still earns its character's bond + let character = match crate::router::custom_card::card_info(mcid)["masterCharacterId"].as_i64() { Some(c) => c, None => continue }; diff --git a/src/router/lottery.rs b/src/router/lottery.rs index 65291ba..1a7f159 100644 --- a/src/router/lottery.rs +++ b/src/router/lottery.rs @@ -2,7 +2,8 @@ use jzon::{array, object, JsonValue}; use actix_web::{web, HttpRequest, Responder}; use rand::RngExt; -use crate::router::{global, userdata, items, databases, Body, Login, Session, Api}; +use crate::router::{global, userdata, items, databases, custom_card, Body, Login, Session, Api}; +use crate::database::custom_card as custom_card_db; pub fn routes(cfg: &mut web::ServiceConfig) { cfg.service( @@ -106,6 +107,85 @@ fn get_random_cards(id: i64, mut count: usize) -> JsonValue { rv } +// The runtime custom-card banner (lottery id 6900001). The CLIENT synthesizes +// its lottery/price/rarity/item masterdata from the catalog; the server only +// handles the draw. Cost and rarity structure mirror the baked SIF1-import +// banners 6110001-6110004 (lottery_price.csv / lottery_rarity.csv): +// price 1 = 11 draws / 3000 free gems, price 2 = 1 draw / 300 free gems +// normal roll r1 6800 / r2 2600 / r3 600; multi draws replace one roll with +// an ensured r2-or-better at 8125 / 1875 +// Wire contract for the drawn items (the client synthesizes matching +// LotteryItemMst rows): master_lottery_item_id = 690000100 + rarity, +// master_lottery_item_number = master_card_id - 150000000 +const CUSTOM_BANNER_RATIO: &[(i64, i64)] = &[(1, 6800), (2, 2600), (3, 600)]; +const CUSTOM_BANNER_ENSURED: &[(i64, i64)] = &[(2, 8125), (3, 1875)]; + +fn custom_banner_price(price_number: i64) -> JsonValue { + match price_number { + 1 => object!{"masterItemId": 0, "consumeType": 1, "count": 11, "price": 3000}, + 2 => object!{"masterItemId": 0, "consumeType": 1, "count": 1, "price": 300}, + _ => JsonValue::Null + } +} + +// One roll: weighted rarity over the rarities that actually have published + +// obtainable cards, then a uniform card within the rarity. None when every +// pool is empty +fn custom_banner_roll(table: &[(i64, i64)], pools: &[Vec; 3], rng: &mut rand::rngs::ThreadRng) -> Option<(i64, i64)> { + let available: Vec<&(i64, i64)> = table.iter().filter(|(rarity, _)| !pools[(*rarity - 1) as usize].is_empty()).collect(); + let total: i64 = available.iter().map(|(_, ratio)| ratio).sum(); + if total <= 0 { + return None; + } + let roll = rng.random_range(1..=total); + let mut cumulative = 0; + for (rarity, ratio) in available { + cumulative += ratio; + if roll <= cumulative { + let pool = &pools[(*rarity - 1) as usize]; + return Some((*rarity, pool[rng.random_range(0..pool.len())])); + } + } + None +} + +// The draw, in the same result shape get_random_cards produces so the stock +// grant loop consumes it unchanged. Empty when there is nothing obtainable - +// the caller bails before charging +fn custom_banner_cards(count: usize) -> JsonValue { + let pools: [Vec; 3] = [ + custom_card_db::obtainable_card_ids(1), + custom_card_db::obtainable_card_ids(2), + custom_card_db::obtainable_card_ids(3) + ]; + let mut rng = rand::rng(); + let mut rv = array![]; + let mut remaining = count; + if count > 1 { + // The ensured slot falls back to a normal roll when no r2/r3 exists + if let Some((rarity, card)) = custom_banner_roll(CUSTOM_BANNER_ENSURED, &pools, &mut rng) + .or_else(|| custom_banner_roll(CUSTOM_BANNER_RATIO, &pools, &mut rng)) { + rv.push(object!{ + "id": card, + "master_card_id": card, + "master_lottery_item_id": 690_000_100 + rarity, + "master_lottery_item_number": card - 150_000_000 + }).unwrap(); + remaining -= 1; + } + } + for _ in 0..remaining { + let Some((rarity, card)) = custom_banner_roll(CUSTOM_BANNER_RATIO, &pools, &mut rng) else { break; }; + rv.push(object!{ + "id": card, + "master_card_id": card, + "master_lottery_item_id": 690_000_100 + rarity, + "master_lottery_item_number": card - 150_000_000 + }).unwrap(); + } + rv +} + fn lottery_day() -> i64 { (global::timestamp() as i64 + 32400) / 86400 } @@ -209,18 +289,46 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl if (6_000_000..7_000_000).contains(&lottery_id) && !crate::router::card::client_supports_custom_cards(&req) { return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); } + // The runtime custom-card banner additionally needs the catalog protocol + let is_custom_banner = lottery_id == custom_card::CUSTOM_LOTTERY_ID; + if is_custom_banner && (custom_card::disabled() || !custom_card::client_supports(&req)) { + return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); + } - let lottery = &databases::LOTTERY[lottery_id.to_string()]; - let lottery_type = lottery["category"].as_i32().unwrap(); - let exchange_id = lottery["exchangeMasterItemId"].as_i64().unwrap_or(0); - - let (price_id, rarity_id) = if is_stepup(lottery_id) && price_number == 1 { - let step = stepup_step(lottery_id, get_draw_count(&user, lottery_id, 1)); - (step["masterLotteryPriceId"].as_i64().unwrap(), step["masterLotteryRarityId"].as_i64().unwrap()) + let (price, cardstogive, lottery_type, exchange_id) = if is_custom_banner { + let price = custom_banner_price(price_number); + if price.is_null() { + return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); + } + let drawn = custom_banner_cards(price["count"].as_usize().unwrap()); + if drawn.is_empty() { + // Nothing published + obtainable: nothing charged, nothing drawn. + // The client only synthesizes the banner when the pool is + // non-empty, so this is a stale-catalog race, not a normal path + return global::api(&req, Some(object!{ + "lottery_item_list": [], + "updated_value_list": {}, + "gift_list": user2["home"]["gift_list"].clone(), + "clear_mission_ids": [], + "draw_count_list": [] + })); + } + (price, drawn, 1, 0) } else { - (lottery["masterLotteryPriceId"].as_i64().unwrap_or(lottery_id), lottery["masterLotteryRarityId"].as_i64().unwrap_or(lottery_id)) + let lottery = &databases::LOTTERY[lottery_id.to_string()]; + let lottery_type = lottery["category"].as_i32().unwrap(); + let exchange_id = lottery["exchangeMasterItemId"].as_i64().unwrap_or(0); + + let (price_id, rarity_id) = if is_stepup(lottery_id) && price_number == 1 { + let step = stepup_step(lottery_id, get_draw_count(&user, lottery_id, 1)); + (step["masterLotteryPriceId"].as_i64().unwrap(), step["masterLotteryRarityId"].as_i64().unwrap()) + } else { + (lottery["masterLotteryPriceId"].as_i64().unwrap_or(lottery_id), lottery["masterLotteryRarityId"].as_i64().unwrap_or(lottery_id)) + }; + let price = databases::PRICE[price_id.to_string()][price_number.to_string()].clone(); + let count = price["count"].as_usize().unwrap(); + (price, get_random_cards(rarity_id, count), lottery_type, exchange_id) }; - let price = databases::PRICE[price_id.to_string()][price_number.to_string()].clone(); items::use_item(&object!{ value: price["masterItemId"].clone(), @@ -228,10 +336,6 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl consumeType: price["consumeType"].clone() }, 1, &mut user); - let count = price["count"].as_usize().unwrap(); - - let cardstogive = get_random_cards(rarity_id, count); - let mut new_cards = array![]; let mut lottery_list = array![]; @@ -295,9 +399,19 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl userdata::save_acc_chats(&key, chats); userdata::save_acc_missions(&key, missions); + // An account holding a runtime custom card needs a catalog-fetching + // client from now on; start.rs enforces it (monotonic, like the level-2 + // flag for the baked band) + if cardstogive.members().any(|card| custom_card::is_custom_runtime(card["master_card_id"].as_i64().unwrap_or(0))) { + userdata::save_protocol_version(&key, custom_card::PROTOCOL_VERSION); + } + global::api(&req, Some(object!{ "lottery_item_list": lottery_list, "updated_value_list": { + // The draw can charge gems (the custom banners do); without this + // the client's balance drifts until the next /api/user pull + "gem": user["gem"].clone(), "card_list": new_cards, "item_list": user["item_list"].clone() }, @@ -317,6 +431,64 @@ async fn lottery_post(req: HttpRequest, Session { key, body }: Session) -> impl #[cfg(test)] mod tests { + use super::*; + + // The runtime banner draws only published + obtainable cards, honors the + // ensured slot, and speaks the agreed lottery_item id/number convention + #[test] + fn runtime_custom_banner_draw() { + let _lock = crate::runtime::lock_test_data_path(); + crate::router::custom_card::tests::wipe(6001); + + // Nothing obtainable: the draw comes back empty (the route then bails + // before charging) + assert!(custom_banner_cards(11).is_empty()); + + let mut r1_ids = Vec::new(); + for seed in 0..3 { + let id = custom_card_db::next_card_id(); + custom_card_db::insert_card(id, 1001, 6001, &object!{ "master_card_id": id, "rarity": 1, "seed": seed }, true, true); + r1_ids.push(id); + } + let r2 = custom_card_db::next_card_id(); + custom_card_db::insert_card(r2, 1001, 6001, &object!{ "master_card_id": r2, "rarity": 2 }, true, true); + let r3 = custom_card_db::next_card_id(); + custom_card_db::insert_card(r3, 1001, 6001, &object!{ "master_card_id": r3, "rarity": 3 }, true, true); + // Draft / unobtainable cards must never come out of the pool + let draft = custom_card_db::next_card_id(); + custom_card_db::insert_card(draft, 1001, 6001, &object!{ "master_card_id": draft, "rarity": 1 }, false, true); + let unobtainable = custom_card_db::next_card_id(); + custom_card_db::insert_card(unobtainable, 1001, 6001, &object!{ "master_card_id": unobtainable, "rarity": 1 }, true, false); + + let drawn = custom_banner_cards(11); + assert_eq!(drawn.len(), 11); + // The ensured slot is drawn first: rarity 2 or 3 + let first = drawn[0]["master_card_id"].as_i64().unwrap(); + assert!(first == r2 || first == r3, "ensured slot drew {}", first); + for card in drawn.members() { + let id = card["master_card_id"].as_i64().unwrap(); + assert!(r1_ids.contains(&id) || id == r2 || id == r3, "drew {}", id); + let rarity = custom_card_db::get_card(id).unwrap()["rarity"].as_i64().unwrap(); + // The contract the client synthesizes its LotteryItemMst rows to + assert_eq!(card["master_lottery_item_id"].as_i64(), Some(690_000_100 + rarity)); + assert_eq!(card["master_lottery_item_number"].as_i64(), Some(id - 150_000_000)); + assert_eq!(card["id"], card["master_card_id"]); + } + + // Single draws have no ensured slot but still only draw the pool + let single = custom_banner_cards(1); + assert_eq!(single.len(), 1); + + // Price rows mirror the baked banners; unknown numbers are refused + assert_eq!(custom_banner_price(1)["price"].as_i64(), Some(3000)); + assert_eq!(custom_banner_price(1)["count"].as_i64(), Some(11)); + assert_eq!(custom_banner_price(2)["price"].as_i64(), Some(300)); + assert_eq!(custom_banner_price(2)["count"].as_i64(), Some(1)); + assert!(custom_banner_price(3).is_null()); + + crate::router::custom_card::tests::wipe(6001); + } + #[test] fn custom_banner_draw() { for lid in [6110001i64, 6110002, 6110003, 6110004] { diff --git a/src/router/start.rs b/src/router/start.rs index dfe8634..b5d4636 100644 --- a/src/router/start.rs +++ b/src/router/start.rs @@ -51,6 +51,13 @@ async fn start(req: HttpRequest, Session { key, body }: Session) -> impl Respond return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); // todo - maybe compatibility layer? } + // An account holding a runtime custom card (protocol 3) can't run on a + // client that only understands the baked band - card_list would carry ids + // it can't resolve + if !crate::router::custom_card::client_supports(&req) && crate::router::custom_card::account_supports(&key) { + return global::api_error(&req, global::RESULT_GAME_VERSION_UPDATED); + } + global::api(&req, Some(object!{ "asset_hash": asset_hash, "token": hex::encode("Hello") //what is this? diff --git a/src/router/tools/guest.rs b/src/router/tools/guest.rs index fb7d6a9..020ba64 100644 --- a/src/router/tools/guest.rs +++ b/src/router/tools/guest.rs @@ -1,6 +1,9 @@ use jzon::{array, object, JsonValue}; +use lazy_static::lazy_static; +use std::collections::HashMap; use crate::router::userdata; use crate::router::global; +use crate::router::{card, custom_card, databases}; fn get_clear_count(user: &JsonValue, level: i32) -> i64 { let mut rv = 0; @@ -67,34 +70,66 @@ pub enum UserView { const DEFAULT_CARD: i64 = 10010001; -fn proxy_card_id(id: i64) -> i64 { - let prefix = id / 10000; - if prefix < 10000 { - return id; - } - let rv = if prefix < 14000 { - (prefix - 9000) * 10000 + 1 - } else { - DEFAULT_CARD +lazy_static! { + // Each character's lowest official card id: the stand-in shown to viewers + // who can't resolve a custom card of that character + static ref OFFICIAL_CARD_BY_CHARACTER: HashMap = { + let mut rv: HashMap = HashMap::new(); + for entry in databases::CARD_LIST.entries() { + let Some(id) = entry.1["id"].as_i64() else { continue; }; + if card::is_custom(id) { + continue; + } + let Some(character) = entry.1["masterCharacterId"].as_i64() else { continue; }; + let slot = rv.entry(character).or_insert(id); + if id < *slot { + *slot = id; + } + } + rv }; - // Not every prefix has a real character behind it - if crate::router::databases::CARD_LIST[rv.to_string()].is_empty() { - return DEFAULT_CARD; - } - rv } -pub fn proxy_user_cards(user: &mut JsonValue) { +// The character behind any card id: baked masterdata first, then the runtime +// custom-card db, then the imported band's id arithmetic (prefix - 9000) +fn card_character(id: i64) -> Option { + let card = &databases::CARD_LIST[id.to_string()]; + if !card.is_empty() { + return card["masterCharacterId"].as_i64(); + } + if custom_card::is_custom_runtime(id) { + return custom_card::character_of(id); + } + Some(id / 10000 - 9000) +} + +// A custom card the viewer can't resolve shows as its character's base +// official card - the right face, never a crash. Characters with no official +// card (custom ones included) fall back to the default +fn proxy_card_id(id: i64) -> i64 { + if !card::is_custom(id) { + return id; + } + let Some(character) = card_character(id) else { + return DEFAULT_CARD; + }; + let Some(rv) = OFFICIAL_CARD_BY_CHARACTER.get(&character) else { + return DEFAULT_CARD; + }; + *rv +} + +pub fn proxy_user_cards(user: &mut JsonValue, protocol: u32) { for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] { let id = user["user"][key].as_i64().unwrap_or(0); - if crate::router::card::is_custom(id) { + if !custom_card::viewer_can_resolve(id, protocol) { user["user"][key] = proxy_card_id(id).into(); } } // A card's id is its master_card_id, so both sides need proxying for key in ["favorite_card", "guest_smile_card", "guest_cool_card", "guest_pure_card"] { let id = user[key]["master_card_id"].as_i64().unwrap_or(0); - if crate::router::card::is_custom(id) { + if !custom_card::viewer_can_resolve(id, protocol) { user[key]["id"] = proxy_card_id(id).into(); user[key]["master_card_id"] = proxy_card_id(id).into(); } @@ -102,7 +137,8 @@ pub fn proxy_user_cards(user: &mut JsonValue) { if !user["main_deck_detail"].is_empty() { let mut used = array![]; for id in user["main_deck_detail"]["deck"]["main_card_ids"].members_mut() { - let card = proxy_card_id(id.as_i64().unwrap_or(0)); + let raw = id.as_i64().unwrap_or(0); + let card = if custom_card::viewer_can_resolve(raw, protocol) { raw } else { proxy_card_id(raw) }; // Whole characters share one proxy, and the client can't hold the // same card twice if card == 0 || used.contains(card) { @@ -116,7 +152,7 @@ pub fn proxy_user_cards(user: &mut JsonValue) { let mut ids = array![]; for card in user["main_deck_detail"]["card_list"].members() { let id = card["master_card_id"].as_i64().unwrap_or(0); - let proxy = proxy_card_id(id); + let proxy = if custom_card::viewer_can_resolve(id, protocol) { id } else { proxy_card_id(id) }; if ids.contains(proxy) { continue; } @@ -132,7 +168,7 @@ pub fn proxy_user_cards(user: &mut JsonValue) { } } -pub fn get_user(id: i64, friends: &JsonValue, view: UserView, custom_cards: bool) -> JsonValue { +pub fn get_user(id: i64, friends: &JsonValue, view: UserView, protocol: u32) -> JsonValue { let user = userdata::get_acc_from_uid(id); if !user["error"].is_empty() { return object!{}; @@ -182,9 +218,124 @@ pub fn get_user(id: i64, friends: &JsonValue, view: UserView, custom_cards: bool } } - if !custom_cards { - proxy_user_cards(&mut rv); - } + proxy_user_cards(&mut rv, protocol); rv } + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::custom_card as db; + + fn wipe(owner: i64) { + for card in db::get_cards_by_owner(owner).members() { + db::delete_card(card["master_card_id"].as_i64().unwrap()); + } + for character in db::get_characters_by_owner(owner).members() { + db::delete_character(character["master_character_id"].as_i64().unwrap()); + } + } + + // The imported band proxies to its own character's base card, not to a + // single default (the old prefix arithmetic collapsed 14000+ to Honoka) + #[test] + fn proxies_resolve_through_the_character() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(5101); + + assert_eq!(proxy_card_id(10010001), 10010001); + assert_eq!(proxy_card_id(0), 0); + assert_eq!(proxy_card_id(100010001), 10010001); + assert_eq!(proxy_card_id(100090001), 10090001); + assert_eq!(proxy_card_id(110010001), 20010001); + assert_eq!(proxy_card_id(130010001), 40010001); + assert_eq!(proxy_card_id(140010001), DEFAULT_CARD); + + // A runtime card on an official character proxies to that character + let id = db::next_card_id(); + db::insert_card(id, 2003, 5101, &jzon::object!{ "master_card_id": id, "rarity": 1 }, true, false); + assert_eq!(proxy_card_id(id), 20030001); + // On a custom character (no official card) it falls to the default + let orphan = db::next_card_id(); + db::insert_card(orphan, db::FIRST_CHARACTER_ID, 5101, &jzon::object!{ "master_card_id": orphan, "rarity": 1 }, true, false); + assert_eq!(proxy_card_id(orphan), DEFAULT_CARD); + // A deleted/unknown runtime id can't resolve a character either + let unknown = db::next_card_id() + 5000; + assert_eq!(proxy_card_id(unknown), DEFAULT_CARD); + // A proxy is never zero and always a real row + for id in [100010001i64, 141720001, 149990001, id, orphan, unknown] { + let proxy = proxy_card_id(id); + assert_ne!(proxy, 0, "id {}", id); + assert!(!databases::CARD_LIST[proxy.to_string()].is_empty(), "id {}", id); + } + + wipe(5101); + } + + // Even a protocol-3 viewer gets a proxy for a draft - published is what + // makes a runtime card resolvable, and older viewers proxy everything + #[test] + fn drafts_and_old_viewers_get_proxies() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(5102); + + let published = db::next_card_id(); + db::insert_card(published, 2003, 5102, &jzon::object!{ "master_card_id": published, "rarity": 1 }, true, false); + let draft = db::next_card_id(); + db::insert_card(draft, 2003, 5102, &jzon::object!{ "master_card_id": draft, "rarity": 1 }, false, false); + + assert!(custom_card::viewer_can_resolve(published, custom_card::PROTOCOL_VERSION)); + assert!(!custom_card::viewer_can_resolve(draft, custom_card::PROTOCOL_VERSION)); + + let mut user = jzon::object!{ + "user": { + "favorite_master_card_id": draft, + "guest_smile_master_card_id": published + }, + "favorite_card": { "id": draft, "master_card_id": draft }, + "guest_smile_card": { "id": published, "master_card_id": published }, + "main_deck_detail": { + "deck": { "main_card_ids": [draft, published, 10010001, 0, 0] }, + "card_list": [ + { "id": draft, "master_card_id": draft }, + { "id": published, "master_card_id": published }, + { "id": 10010001, "master_card_id": 10010001 } + ] + } + }; + proxy_user_cards(&mut user, custom_card::PROTOCOL_VERSION); + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(20030001)); + assert_eq!(user["user"]["guest_smile_master_card_id"].as_i64(), Some(published)); + assert_eq!(user["favorite_card"]["master_card_id"].as_i64(), Some(20030001)); + assert_eq!(user["guest_smile_card"]["master_card_id"].as_i64(), Some(published)); + assert_eq!(user["main_deck_detail"]["deck"]["main_card_ids"][0].as_i64(), Some(20030001)); + assert_eq!(user["main_deck_detail"]["deck"]["main_card_ids"][1].as_i64(), Some(published)); + assert_eq!(user["main_deck_detail"]["deck"]["main_card_ids"][2].as_i64(), Some(10010001)); + assert_eq!(user["main_deck_detail"]["card_list"].len(), 3); + for card in user["main_deck_detail"]["card_list"].members() { + assert_ne!(card["master_card_id"].as_i64(), Some(draft)); + } + + // Protocol 0-2 viewers proxy even the published runtime card + for protocol in [0u32, 1, 2] { + let mut user = jzon::object!{ + "user": { "favorite_master_card_id": published }, + "favorite_card": { "id": published, "master_card_id": published } + }; + proxy_user_cards(&mut user, protocol); + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(20030001), "protocol {}", protocol); + } + // The baked import band needs only protocol 2 + let mut user = jzon::object!{ + "user": { "favorite_master_card_id": 100010001 }, + "favorite_card": { "id": 100010001, "master_card_id": 100010001 } + }; + proxy_user_cards(&mut user, 2); + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(100010001)); + proxy_user_cards(&mut user, 1); + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), Some(10010001)); + + wipe(5102); + } +} diff --git a/src/router/user.rs b/src/router/user.rs index 8413117..fae5f39 100644 --- a/src/router/user.rs +++ b/src/router/user.rs @@ -67,6 +67,12 @@ async fn user(req: HttpRequest, Login(key): Login) -> impl Responder { } } + // Runtime custom cards are unresolvable below protocol 3. start.rs blocks + // flagged accounts on old clients, so this is belt-and-braces + if !crate::router::custom_card::client_supports(&req) { + crate::router::custom_card::strip_unsupported(&mut user); + } + global::api(&req, Some(user)) } @@ -243,12 +249,12 @@ async fn migration(Body(body): Body) -> impl Responder { async fn detail(req: HttpRequest, Session { key, body }: Session) -> impl Responder { let friends = userdata::get_acc_friends(&key); - let custom_cards = crate::router::card::client_supports_custom_cards(&req); - + let protocol = crate::router::global::client_protocol_version(&req); + let mut user_detail_list = array![]; for data in body["user_ids"].members() { let uid = data.as_i64().unwrap(); - let user = guest::get_user(uid, &friends, guest::UserView::Detail, custom_cards); + let user = guest::get_user(uid, &friends, guest::UserView::Detail, protocol); user_detail_list.push(user).unwrap(); } Api(Some(object!{ diff --git a/src/router/userdata/mod.rs b/src/router/userdata/mod.rs index b5a5224..fd6737a 100644 --- a/src/router/userdata/mod.rs +++ b/src/router/userdata/mod.rs @@ -9,6 +9,7 @@ use crate::router::global; use crate::router::items; use crate::router::card; use crate::database::custom_song; +use crate::database::custom_card; use crate::sql::SQLite; use crate::include_file; @@ -332,10 +333,66 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool { true } +// Deleted custom cards leave stale card_list rows behind - and a card_list id +// the client can't resolve aborts its whole login. Wiped lazily when the +// userdata is pulled, mirroring remove_deleted_custom_songs: only the runtime +// band is a candidate (official/imported ids never are), ids are never +// reused, so the wipe is final. A card that still exists but is unpublished +// is NOT wiped - existence is what's checked, and the catalog keeps serving +// owned ids (custom_card::owned_runtime_ids) so holders still resolve them +fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool { + // Feature off: never touch custom_cards.db, leave userdata untouched + if crate::router::custom_card::disabled() { + return false; + } + let mut candidates = array![]; + for data in user["card_list"].members() { + let id = data["master_card_id"].as_i64().unwrap_or(0); + if id >= custom_card::FIRST_CARD_ID && id <= custom_card::LAST_CARD_ID && !candidates.contains(id) { + candidates.push(id).unwrap(); + } + } + if candidates.is_empty() { + return false; + } + let dead = custom_card::dead_card_ids(&candidates); + if dead.is_empty() { + return false; + } + let mut i = 0; + while i < user["card_list"].len() { + if dead.contains(user["card_list"][i]["master_card_id"].as_i64().unwrap_or(0)) { + user["card_list"].array_remove(i); + } else { + i += 1; + } + } + for deck in user["deck_list"].members_mut() { + for slot in deck["main_card_ids"].members_mut() { + if dead.contains(slot.as_i64().unwrap_or(0)) { + *slot = (0).into(); + } + } + } + // A dead favorite/guest card repoints to the account's first remaining + // card (every account has its tutorial cards; the fallback can't trigger + // in practice) + let fallback = user["card_list"][0]["master_card_id"].as_i64().unwrap_or(10010001); + for key in ["favorite_master_card_id", "guest_smile_master_card_id", "guest_cool_master_card_id", "guest_pure_master_card_id"] { + if dead.contains(user["user"][key].as_i64().unwrap_or(0)) { + user["user"][key] = fallback.into(); + } + } + true +} + pub fn get_acc(auth_key: &str) -> JsonValue { let mut user = get_data(auth_key, "userdata"); cleanup_account(&mut user); let mut changed = remove_deleted_custom_songs(&mut user); + if remove_deleted_custom_cards(&mut user) { + changed = true; + } if items::lp_modification(&mut user, 0, false) { changed = true; @@ -777,4 +834,57 @@ mod tests { assert!(!stored["live_list"].members().any(|data| data["master_live_id"] == deleted_id)); assert!(!stored["live_mission_list"].members().any(|data| data["master_live_id"] == deleted_id)); } + + // User draws a custom card -> the card is deleted -> the next userdata + // pull drops the dead card from card_list, empties its deck slots and + // repoints favorite/guest references; unpublished-but-alive cards survive + #[test] + fn deleted_custom_card_records_are_wiped_on_pull() { + let _lock = crate::runtime::lock_test_data_path(); + + let token = "userdata-card-test-token"; + let mut user = get_acc(token); + + let deleted_id = custom_card::next_card_id(); + custom_card::insert_card(deleted_id, 1001, 1, &jzon::object!{ "master_card_id": deleted_id, "rarity": 1 }, true, true); + // Exists but was unpublished - must survive the wipe + let unpublished_id = custom_card::next_card_id(); + custom_card::insert_card(unpublished_id, 1001, 1, &jzon::object!{ "master_card_id": unpublished_id, "rarity": 1 }, false, false); + + for id in [deleted_id, unpublished_id] { + user["card_list"].push(jzon::object!{ + "id": id, + "master_card_id": id, + "exp": 0, + "skill_exp": 0, + "evolve": [], + "created_date_time": 0 + }).unwrap(); + } + user["deck_list"][0]["main_card_ids"][0] = deleted_id.into(); + user["deck_list"][0]["main_card_ids"][1] = unpublished_id.into(); + user["user"]["favorite_master_card_id"] = deleted_id.into(); + save_acc(token, user); + + // Both cards still exist: nothing gets wiped + let user = get_acc(token); + assert!(user["card_list"].members().any(|data| data["master_card_id"] == deleted_id)); + + custom_card::delete_card(deleted_id); + + // The next pull drops the dead card's records and only those + let user = get_acc(token); + assert!(!user["card_list"].members().any(|data| data["master_card_id"] == deleted_id)); + assert!(user["card_list"].members().any(|data| data["master_card_id"] == unpublished_id)); + assert_eq!(user["deck_list"][0]["main_card_ids"][0].as_i64(), Some(0)); + assert_eq!(user["deck_list"][0]["main_card_ids"][1].as_i64(), Some(unpublished_id)); + // The dead favorite repointed to the first remaining card + assert_eq!(user["user"]["favorite_master_card_id"].as_i64(), user["card_list"][0]["master_card_id"].as_i64()); + + // The wipe persisted to the database, not just the served copy + let stored = get_data(token, "userdata"); + assert!(!stored["card_list"].members().any(|data| data["master_card_id"] == deleted_id)); + + custom_card::delete_card(unpublished_id); + } } diff --git a/src/router/webui.rs b/src/router/webui.rs index d274fbd..3b4d833 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -4,12 +4,13 @@ use actix_web::{ http::header::HeaderValue, http::header::ContentType }; -use jzon::{JsonValue, object}; +use jzon::{array, JsonValue, object}; use lazy_static::lazy_static; use include_dir::{include_dir, Dir}; use std::fs; use crate::include_file; +use crate::database::permissions; use crate::router::{userdata, items}; use crate::router::databases::csv::Region; @@ -30,6 +31,12 @@ pub fn get_login_token(req: &HttpRequest) -> Option { Some(cookies.split("ew_token=").last().unwrap_or("").split(';').collect::>()[0].to_string()) } +fn session_uid(req: &HttpRequest) -> Option { + let token = get_login_token(req)?; + let login_token = userdata::webui_login_token(&token)?; + userdata::get_acc(&login_token)["user"]["id"].as_i64() +} + pub fn error(msg: &str) -> HttpResponse { let resp = object!{ result: "ERR", @@ -221,6 +228,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse { data: { account_import: get_config()["import"].as_bool().unwrap(), custom_songs: !crate::router::custom_song::disabled(), + custom_cards: !crate::router::custom_card::disabled(), links: { global: args.global_android, japan: args.japan_android, @@ -358,6 +366,192 @@ pub fn list_items(_req: HttpRequest) -> HttpResponse { .body(jzon::stringify(ITEM.clone())) } +lazy_static! { + // The selectable character list for the custom-card form: every official + // and SIF1-imported character in the baked csv, by name. The import band + // starts at 5001 (5001-5172 + 6001-6009); official ids top out at 4014 + static ref CHARACTER_CHOICES: JsonValue = { + let mut rv = jzon::array![]; + for row in crate::router::databases::csv::table(Region::Jp, "character").members() { + let Some(id) = row["id"].as_i64() else { continue; }; + rv.push(object!{ + id: id, + name: row["name"].clone(), + name_en: row["nameEn"].clone(), + category: if id >= 5000 { "imported" } else { "official" } + }).unwrap(); + } + rv + }; + + // The skill_center table with its display strings, for picking a center + // skill by name instead of by raw id + static ref SKILL_CENTER_CHOICES: JsonValue = { + let mut en_rows = object!{}; + for row in crate::router::databases::csv::table(Region::En, "skill_center").members() { + en_rows[row["id"].to_string()] = row.clone(); + } + let mut rv = jzon::array![]; + for row in crate::router::databases::csv::table(Region::Jp, "skill_center").members() { + let en = &en_rows[row["id"].to_string()]; + rv.push(object!{ + id: row["id"].clone(), + name: row["name"].clone(), + name_en: en["name"].clone(), + detail_text: row["detailText"].clone(), + detail_text_en: en["detailText"].clone() + }).unwrap(); + } + rv + }; +} + +// The characters a card upload may reference, for the webui's searchable +// picker: the baked official + imported list, plus the custom characters +// this session may build on (their own and the publicly visible ones) +pub fn list_characters(req: HttpRequest) -> HttpResponse { + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let mut characters = CHARACTER_CHOICES.clone(); + if !crate::router::custom_card::disabled() { + for character in crate::database::custom_card::get_selectable_characters(uid).members() { + characters.push(object!{ + id: character["master_character_id"].clone(), + name: character["name"].clone(), + name_en: character["name_en"].clone(), + category: "custom" + }).unwrap(); + } + } + let resp = object!{ + result: "OK", + characters: characters + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +pub fn list_skill_centers(req: HttpRequest) -> HttpResponse { + if session_uid(&req).is_none() { + return error("Not logged in"); + } + let resp = object!{ + result: "OK", + skill_centers: SKILL_CENTER_CHOICES.clone() + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// The concrete upload bounds (per-rarity stat caps, enum ranges, skill array +// lengths) so the form enforces them before submitting +pub fn custom_card_limits(req: HttpRequest) -> HttpResponse { + if session_uid(&req).is_none() { + return error("Not logged in"); + } + let resp = object!{ + result: "OK", + data: crate::router::custom_card::upload_limits() + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// The requesting user's own effective scopes, for webui nav gating. Any +// session may ask - it only ever reveals what the user themselves holds +pub fn my_scopes(req: HttpRequest) -> HttpResponse { + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let resp = object!{ + result: "OK", + data: { + uid: uid, + scopes: permissions::scopes_for(uid), + can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD), + can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH), + can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT), + can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) + || permissions::has(uid, permissions::PERMISSION_REVOKE) + } + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// The admin view: every grant plus the grantable vocabulary. Needs a +// permission.* scope - my_scopes is the anyone-can-ask endpoint +pub fn list_permissions(req: HttpRequest) -> HttpResponse { + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let can_grant = permissions::has(uid, permissions::PERMISSION_GRANT); + let can_revoke = permissions::has(uid, permissions::PERMISSION_REVOKE); + if !can_grant && !can_revoke { + return error("You do not have permission to manage scopes"); + } + let mut available = array![]; + for scope in permissions::SCOPES { + available.push(*scope).unwrap(); + } + let resp = object!{ + result: "OK", + data: { + uid: uid, + can_grant: can_grant, + can_revoke: can_revoke, + scopes: permissions::scopes_for(uid), + available: available, + grants: permissions::grants() + } + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +pub fn grant_permission(req: HttpRequest, body: String) -> HttpResponse { + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + let target = body["uid"].as_i64().unwrap_or(0); + let scope = body["scope"].to_string(); + if userdata::get_login_token(target) == String::new() { + return error(&format!("User {} does not exist", target)); + } + if let Err(e) = permissions::grant(target, &scope, uid) { + return error(&e); + } + let resp = object!{ + result: "OK" + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +pub fn revoke_permission(req: HttpRequest, body: String) -> HttpResponse { + let Some(uid) = session_uid(&req) else { + return error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + if let Err(e) = permissions::revoke(body["uid"].as_i64().unwrap_or(0), &body["scope"].to_string(), uid) { + return error(&e); + } + let resp = object!{ + result: "OK" + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse { let token = get_login_token(&req); if token.is_none() { @@ -397,3 +591,32 @@ pub fn cheat(req: HttpRequest, _body: String) -> HttpResponse { .insert_header(ContentType::json()) .body(jzon::stringify(resp)) } + +#[cfg(test)] +mod tests { + use super::*; + + // The picker lists the card form searches by name: every baked character + // (official + SIF1 import, badged apart) and every skill_center row with + // its JP and EN display strings + #[test] + fn character_and_skill_center_choices_are_wellformed() { + assert!(CHARACTER_CHOICES.len() > 200, "got {}", CHARACTER_CHOICES.len()); + let honoka = CHARACTER_CHOICES.members().find(|row| row["id"] == 1001).unwrap(); + assert_eq!(honoka["name_en"].as_str(), Some("Honoka Kosaka")); + assert_eq!(honoka["category"].as_str(), Some("official")); + let imported = CHARACTER_CHOICES.members().find(|row| row["id"] == 5153).unwrap(); + assert_eq!(imported["category"].as_str(), Some("imported")); + for row in CHARACTER_CHOICES.members() { + assert!(row["id"].as_i64().unwrap() > 0); + assert!(!row["name"].to_string().is_empty()); + } + + assert!(SKILL_CENTER_CHOICES.len() > 60, "got {}", SKILL_CENTER_CHOICES.len()); + let first = SKILL_CENTER_CHOICES.members().find(|row| row["id"] == 100001).unwrap(); + assert_eq!(first["name_en"].as_str(), Some("Smile Heart")); + assert_eq!(first["detail_text_en"].as_str(), Some("Smile points increased by 3%")); + assert!(!first["name"].to_string().is_empty()); + assert!(!first["detail_text"].to_string().is_empty()); + } +} diff --git a/src/runtime.rs b/src/runtime.rs index 6f56d0a..c854b18 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -7,6 +7,7 @@ use std::fs; lazy_static! { static ref RUNNING: RwLock = RwLock::new(false); static ref DATAPATH: RwLock = RwLock::new(String::new()); + static ref OWNERS: RwLock> = RwLock::new(Vec::new()); static ref MASTERDATA_PATH: RwLock = RwLock::new(String::new()); static ref MASTERDATA_WARNED: Mutex> = Mutex::new(HashSet::new()); static ref EASTER: RwLock = RwLock::new(false); @@ -23,6 +24,7 @@ pub struct HostConfig { pub jp_android_asset_hash: String, pub en_android_asset_hash: String, pub enable_custom_songs: bool, + pub enable_custom_cards: bool, } // Lets an embedding app (or the tests) enable the opt-in custom songs feature @@ -31,6 +33,22 @@ pub fn set_enable_custom_songs(enabled: bool) { HOST_CONFIG.write().unwrap().enable_custom_songs = enabled; } +pub fn set_enable_custom_cards(enabled: bool) { + HOST_CONFIG.write().unwrap().enable_custom_cards = enabled; +} + +// The --owner uids: the permission system's bootstrap grantors. Process-level +// state rather than db rows so they work on a fresh install and can't be +// revoked through the webui +pub fn update_owners(uids: &[i64]) { + let mut w = OWNERS.write().unwrap(); + *w = uids.to_vec(); +} + +pub fn get_owners() -> Vec { + OWNERS.read().unwrap().clone() +} + pub fn set_running(running: bool) { let mut w = RUNNING.write().unwrap(); *w = running; @@ -144,11 +162,14 @@ pub fn overlay_args(args: &mut crate::options::Args) { } overlay_str!(jp_android_asset_hash); overlay_str!(en_android_asset_hash); - // Overlay only ever enables the feature; a command-line --enable-custom-songs - // is never overridden back to off + // Overlay only ever enables the features; a command-line --enable-custom-songs + // / --enable-custom-cards is never overridden back to off if cfg.enable_custom_songs { args.enable_custom_songs = true; } + if cfg.enable_custom_cards { + args.enable_custom_cards = true; + } } // idk why an ai put tests here but they are here now. Yay tests???? @@ -167,7 +188,9 @@ lazy_static! { pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> { let guard = crate::lock_onto_mutex!(TEST_LOCK); update_data_path(&TEST_DATA_DIR); - // The feature is off by default; tests exercise it, so turn it on while holding the lock + // The features are off by default; tests exercise them, so turn them on + // while holding the lock set_enable_custom_songs(true); + set_enable_custom_cards(true); guard } diff --git a/webui b/webui index 896ef1c..1561074 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 896ef1ccda34b3bd9fcf6b5bea562dd1eaa7c62a +Subproject commit 1561074cc5a650952b50572cac9b16a63869cab6