diff --git a/src/router.rs b/src/router.rs index 88537b5..0a56f2c 100644 --- a/src/router.rs +++ b/src/router.rs @@ -22,6 +22,7 @@ pub mod card; pub mod shop; pub mod custom_song; pub mod custom_card; +pub mod rich_text; pub mod webui; pub mod clear_rate; pub mod exchange; diff --git a/src/router/custom_card.rs b/src/router/custom_card.rs index 92d344f..4dd6b4b 100644 --- a/src/router/custom_card.rs +++ b/src/router/custom_card.rs @@ -9,7 +9,7 @@ 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, global, rich_text, userdata, webui, Login, Api}; use crate::router::databases::csv::{table, Region}; use crate::router::custom_song::audio; use crate::database::custom_card as database; @@ -859,6 +859,10 @@ fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue stored_line.map(|line| line[suffix].as_str().unwrap_or("").to_string()).unwrap_or_default() } }; + // Captions are drawn as live subtitles by the same unescaped TMP path + for suffix in ["text", "text_en"] { + rich_text::reject_tags(&format!("'{}_{}'", base, suffix), &text(suffix), &[])?; + } if let Some(bytes) = file_of(fields, &base) { if bytes.len() > MAX_VOICE_BYTES { return Err(format!("'{}' exceeds the {} MB per-file limit for voicelines", base, MAX_VOICE_BYTES / (1024 * 1024))); @@ -950,9 +954,13 @@ fn validate_character_ref(uid: i64, master_character_id: i64) -> Result<(), Stri // 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() { + let text = text_of(fields, key, stored, key); + if text.is_empty() { return Err(format!("{} is required", label)); } + // The client renders these through TMP with rich text on and no escaping; official card + // names carry no markup (rich_text.rs) + rich_text::reject_tags(label, &text, &[])?; } let card_type = number_of(fields, "type", stored, "type"); @@ -995,9 +1003,13 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st ("skill_detail_text", "Skill description"), ("skill_detail_text_en", "Skill English description") ] { - if text_of(fields, key, &stored_skill, &key["skill_".len()..]).is_empty() { + let text = text_of(fields, key, &stored_skill, &key["skill_".len()..]); + if text.is_empty() { return Err(format!("{} is required", label)); } + // Descriptions may wrap, like the official skill_center detailText rows; nothing else + let allowed: &[&str] = if key.ends_with("detail_text") || key.ends_with("detail_text_en") { &["br"] } else { &[] }; + rich_text::reject_tags(label, &text, allowed)?; } let trigger = number_of(fields, "skill_trigger", &stored_skill, "trigger"); @@ -1113,9 +1125,24 @@ fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue ("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() { + let text = text_of(fields, key, stored, &key["character_".len()..]); + if text.is_empty() { return Err(format!("{} is required", label)); } + // The gacha display name is the ONE column official data formats, and only ever with + // (character.csv nameRichtextGacha); descriptions may wrap; names carry nothing + let allowed: &[&str] = if key.starts_with("character_name_richtext_gacha") { + &["size"] + } else if key.starts_with("character_detail_text") { + &["br"] + } else { + &[] + }; + rich_text::reject_tags(label, &text, allowed)?; + } + // The remaining free-text profile columns: shown on the member page, same TMP treatment + for key in ["height", "blood_type", "blood_type_en", "birthday", "birthday_en", "voice_actor", "voice_actor_en"] { + rich_text::reject_tags(key, &text_of(fields, &format!("character_{}", key), stored, key), &[])?; } for key in ["character_image_color", "character_image_color_dark"] { if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) { @@ -1656,6 +1683,49 @@ pub mod tests { rv } + // Character text and voiceline captions land in the same unescaped TMP labels as card text. + // The gacha display name is the one column official data formats, and only with + #[test] + fn character_text_may_not_carry_rich_text_tags() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(4014); + + let run = |fields: &Fields| with_permissions(4014, &[permissions::CARD_UPLOAD], || create_character(4014, fields)); + let mutated = |key: &str, value: &str| { + let mut fields = character_fields(); + field(&mut fields, key, value); + fields + }; + + for key in ["character_name", "character_name_en", "character_name_ruby", "character_name_ruby_en"] { + let error = run(&mutated(key, "x")).unwrap_err(); + assert!(error.contains(""), "{} -> {}", key, error); + } + // Profile columns too + assert!(run(&mutated("character_height", "170cm")).unwrap_err().contains("")); + assert!(run(&mutated("character_voice_actor", "")).unwrap_err().contains("")); + // Descriptions wrap; nothing else + assert!(run(&mutated("character_detail_text", "x")).unwrap_err().contains("")); + // The gacha display name keeps , still nothing else + assert!(run(&mutated("character_name_richtext_gacha", "")).unwrap_err().contains("")); + + // Voiceline captions are subtitles - a caption with a tag is rejected before anything + // is written + let mut fields = character_fields(); + fields.insert(String::from("voice_live_start_1"), test_wav(1.0, 1)); + field(&mut fields, "voice_live_start_1_text", "x"); + assert!(run(&fields).unwrap_err().contains("")); + + // The official shape uploads: on the gacha name,
in a description + let mut fields = character_fields(); + field(&mut fields, "character_name_richtext_gacha", "Test
"); + field(&mut fields, "character_detail_text", "line one
line two"); + let id = run(&fields).unwrap(); + let character = database::get_character(id).unwrap(); + assert_eq!(character["name_richtext_gacha"].as_str(), Some("Test
")); + assert_eq!(character["detail_text"].as_str(), Some("line one
line two")); + } + // Voicelines: transcode to ogg, wire shape + captions, renumbering, // caption-only edits, replacement GC, deletion, the caps, and the // content-addressed voice route index @@ -1939,6 +2009,16 @@ pub mod tests { 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")); + // Rich-text tags: every one of these strings is drawn by TMP with rich text ON and no + // escaping, so markup in a name or a caption mangles the screens it lands on + for key in ["name", "name_en", "skill_name", "skill_name_en"] { + let error = run(&mutated(key, "x")).unwrap_err(); + assert!(error.contains(""), "{} -> {}", key, error); + } + assert!(run(&mutated("skill_detail_text", "")).unwrap_err().contains("")); + // A description may wrap and "<3" is not a tag, so neither is rejected (proved without + // creating cards in rich_text's own unit tests - this test only exercises rejections) + // 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")); diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index 567a3e8..e6b60c0 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::fs; use std::sync::Mutex; -use crate::router::{global, userdata, webui, Login, Api}; +use crate::router::{global, rich_text, userdata, webui, Login, Api}; use crate::database::custom_song as database; use crate::runtime::get_data_path; use crate::lock_onto_mutex; @@ -102,9 +102,21 @@ async fn list(Login(key): Login) -> impl Responder { return Api(None); } let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); + let mut songs = database::get_songs_for_user(uid); + for song in songs.members_mut() { + // Additive field: the client turns it into the song's detail-info credit line (the + // staff-credits text the live loading screen and the music library show). Old clients + // that don't know the field simply ignore it. The name is an ACCOUNT name, which the + // profile route stores verbatim, so it is stripped of rich-text tags before it lands in + // a TMP rich-text field (rich_text.rs) + let Some(music_id) = song["music_id"].as_i64() else { continue; }; + let owner = database::get_song_owner(music_id).unwrap_or(0); + let name = userdata::get_name_and_rank(owner)["user_name"].as_str().unwrap_or("").to_string(); + song["uploader"] = rich_text::strip_tags(&name).into(); + } Api(Some(object!{ "revision": database::get_revision(), - "songs": database::get_songs_for_user(uid) + "songs": songs })) } @@ -349,6 +361,25 @@ fn default_scores() -> (JsonValue, JsonValue) { }) } +// Every free-text column the client shows for a song. It renders them through TMP with rich +// text on and no escaping (rich_text.rs), and the official music table carries no markup in ANY +// of these columns -
only ever appears in detailInfo - so no tag is allowed in any of them. +fn validate_song_text( + name: &str, name_en: &str, short_name: &str, kana: &str, artist: &str, artist_en: &str +) -> Result<(), String> { + for (label, text) in [ + ("Song name", name), + ("Song English name", name_en), + ("Short name", short_name), + ("Name reading", kana), + ("Artist", artist), + ("English artist", artist_en) + ] { + rich_text::reject_tags(label, text, &[])?; + } + Ok(()) +} + // Combo-mission targets. Official live_mission_combo rows are round(hardest difficulty's // full combo * 0.2/0.4/0.6/0.8) - verified against 626 of the 637 shipped rows (the 11 // outliers are songs that gained a harder difficulty after the mission row was authored). @@ -419,6 +450,15 @@ fn create_song(uid: i64, fields: &HashMap>) -> Result>) -> Result<(), S return Err(format!("Unknown band category '{}'", band_category)); } + // The RESULTING text, so an edit that leaves a field alone is checked against what stays + validate_song_text( + &name, &text("name_en"), &text("short_name"), &text("kana"), &artist, &text("artist_en") + )?; + // (level, replacement chart json + original SIF1 bytes, full_combo, level_number) let mut charts: Vec<(i64, Option<(JsonValue, Vec)>, i64, i64)> = Vec::new(); let mut removed: Vec = Vec::new(); @@ -1260,6 +1305,82 @@ mod tests { assert_eq!(database::get_revision(), revision + 1); } + // Song text is rendered by TMP with rich text on and no escaping, so a tag in a name or an + // artist is rejected at upload and at edit. A '<' that TMP wouldn't read as a tag survives. + #[test] + fn song_text_may_not_carry_rich_text_tags() { + let _lock = crate::runtime::lock_test_data_path(); + + let base = || { + let mut fields = HashMap::new(); + field(&mut fields, "name", "Tag Check"); + field(&mut fields, "artist", "Tag Artist"); + field(&mut fields, "attribute", "1"); + field(&mut fields, "level_number_1", "5"); + fields.insert(String::from("jacket"), test_png()); + fields.insert(String::from("audio"), test_ogg_tone(1210.0)); + fields.insert(String::from("chart_1"), test_chart()); + fields + }; + + for (key, label) in [ + ("name", "Song name"), + ("name_en", "Song English name"), + ("short_name", "Short name"), + ("kana", "Name reading"), + ("artist", "Artist"), + ("artist_en", "English artist") + ] { + let mut fields = base(); + field(&mut fields, key, "boom"); + let error = create_song(8888, &fields).unwrap_err(); + assert!(error.contains(label), "{} -> {}", key, error); + assert!(error.contains(""), "{} -> {}", key, error); + assert!(database::get_song(8888).is_none()); + } + + // "<3" is not a tag, so it uploads + let mut fields = base(); + field(&mut fields, "name", "I <3 LIVE"); + let music_id = create_song(8888, &fields).unwrap(); + assert_eq!(database::get_song(music_id).unwrap()["name"], "I <3 LIVE"); + + // Edits are held to the same rule, and the stored song survives the rejection + let before = database::get_song(music_id).unwrap(); + let mut fields = HashMap::new(); + field(&mut fields, "artist", ""); + assert!(update_song(music_id, &fields).unwrap_err().contains("Artist")); + assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before)); + } + + // The catalog the GAME reads carries the uploader's account name, which the client turns + // into the song's detail-info credit line. Account names are stored verbatim by the profile + // route, so the catalog strips rich-text tags out of them + #[test] + fn the_game_catalog_carries_a_tag_free_uploader_name() { + let _lock = crate::runtime::lock_test_data_path(); + + let uid = 1; + let mut fields = HashMap::new(); + field(&mut fields, "name", "Credited"); + field(&mut fields, "artist", "Credit Artist"); + field(&mut fields, "attribute", "2"); + field(&mut fields, "level_number_1", "5"); + fields.insert(String::from("jacket"), test_png()); + fields.insert(String::from("audio"), test_ogg_tone(1320.0)); + fields.insert(String::from("chart_1"), test_chart()); + let music_id = create_song(uid, &fields).unwrap(); + + let songs = database::get_songs_for_user(uid); + let song = songs.members().find(|s| s["music_id"] == music_id).unwrap(); + // The router adds the field; the stored blob never holds it + assert!(song["uploader"].is_null()); + + // The tag stripper is what the router applies to the account name + assert_eq!(rich_text::strip_tags("Nozomi"), "Nozomi"); + assert_eq!(rich_text::strip_tags("Honoka"), "Honoka"); + } + // A chart that outlives its audio is rejected on upload AND on edit (from either side - // swapping in a longer chart, or shorter audio under charts that stay). The live ends when // the music does, so those notes would never be judged. A note at t=0 is fine: the 2.0s diff --git a/webui b/webui index 124065a..700dae6 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 124065a3138dcb64188823b2b07cb101dfa69d62 +Subproject commit 700dae60a30760f99c7687337359aab295cc654d