From b4cd6968d8705662bfc885a21e8c28067dbf1be6 Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Sat, 1 Aug 2026 12:09:49 -0500 Subject: [PATCH] Show public custom song titles on the clear rate page --- src/database/custom_song.rs | 18 +++++++++++++ src/router/clear_rate.rs | 17 ++++++++++-- src/router/custom_song.rs | 54 +++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/database/custom_song.rs b/src/database/custom_song.rs index bd0d75f..d8ba7ad 100644 --- a/src/database/custom_song.rs +++ b/src/database/custom_song.rs @@ -239,6 +239,24 @@ pub fn export_allowed(music_id: i64, viewer: Option) -> Result<(), &'static Ok(()) } +// The display title for a PUBLIC custom song, for the clear-rate page. +// Private/shared songs return None - their names (and existence) must not +// leak beyond the visibility rules, so this never falls back past the +// explicit visibility check (an absent row is None, not "public") +pub fn public_song_title(music_id: i64, english: bool) -> Option { + if !(FIRST_MUSIC_ID..=LAST_MUSIC_ID).contains(&music_id) { + return None; + } + let visibility = DATABASE.lock_and_select("SELECT visibility FROM songs WHERE music_id=?1", params!(music_id)).ok()?; + if visibility != "public" { + return None; + } + let song = get_song(music_id)?; + let name = song["name"].as_str().unwrap_or("").to_string(); + let name_en = song["name_en"].as_str().unwrap_or("").to_string(); + Some(if english && !name_en.is_empty() { name_en } else { name }) +} + pub fn get_music_ids_for_user(user_id: i64) -> JsonValue { DATABASE.lock_and_select_all(" SELECT music_id FROM songs diff --git a/src/router/clear_rate.rs b/src/router/clear_rate.rs index 40b811d..f30b788 100644 --- a/src/router/clear_rate.rs +++ b/src/router/clear_rate.rs @@ -173,9 +173,22 @@ fn get_song_title(live_id: i32, english: bool) -> String { if !details.is_null() { return details["name"].to_string(); } + // Custom songs aren't in the official music mst (their live_id == + // music_id). PUBLIC ones show their real title; private/shared ones are + // already filtered out of the page and would stay "Unknown Song" even if + // one slipped through - the lookup only ever answers for public songs + if let Some(title) = crate::router::custom_song::public_song_title(live_id as i64, english) { + return title; + } String::from("Unknown Song") } +// Titles land inside HTML text and attributes; custom song names are user +// input, so escape them (official names contain nothing that needs it) +fn html_escape(text: &str) -> String { + text.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + fn get_pass_percent(failed: i64, pass: i64) -> String { let total = (failed + pass) as f64; if failed + pass == 0 { @@ -315,8 +328,8 @@ fn get_html() -> JsonValue { if total == 0 { 0.0 } else { pass as f64 / total as f64 } }; - let title_jp = get_song_title(info.live_id, false); - let title_en = get_song_title(info.live_id, true); + let title_jp = html_escape(&get_song_title(info.live_id, false)); + let title_en = html_escape(&get_song_title(info.live_id, true)); let normal_txt = get_pass_percent(info.normal_failed, info.normal_pass); let hard_txt = get_pass_percent(info.hard_failed, info.hard_pass); diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index cb4c0b2..4e1c2f6 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -124,6 +124,15 @@ pub fn hidden_live_ids() -> JsonValue { database::non_public_music_ids() } +// The clear-rate page shows real titles for PUBLIC custom songs; anything +// else stays exactly as hidden as before +pub fn public_song_title(music_id: i64, english: bool) -> Option { + if disabled() { + return None; + } + database::public_song_title(music_id, english) +} + pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue { if disabled() { return array![]; @@ -1579,6 +1588,51 @@ mod tests { assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK); } + // The public clear-rate HTML page shows the REAL title for public custom + // songs (escaped - names are user input), keeps private songs entirely + // absent, and prefers name_en for the EN title attribute + #[test] + fn clearrate_html_shows_public_custom_song_titles() { + use actix_web::test::TestRequest; + use crate::router::clear_rate; + let _lock = crate::runtime::lock_test_data_path(); + + let public_id = database::next_music_id(); + database::insert_song(public_id, 6100, &object!{ + music_id: public_id, + name: "Public & \"Co\"", + name_en: "Public Song EN" + }, "public", &array![], false); + let private_id = database::next_music_id(); + database::insert_song(private_id, 6100, &object!{ + music_id: private_id, + name: "Top Secret Anthem" + }, "private", &array![], false); + for id in [public_id, private_id] { + clear_rate::live_completed(id, 1, false, 100, 6100); + } + clear_rate::invalidate_cache(); + + let html = actix_web::rt::System::new().block_on(async { + let resp = clear_rate::clearrate_html(TestRequest::default().to_http_request()).await; + let body = actix_web::body::to_bytes(resp.into_body()).await.unwrap(); + String::from_utf8_lossy(&body).to_string() + }); + // The public song's real (escaped) title, JP cell and EN attribute + assert!(html.contains("Public <Song> & "Co""), "public title missing"); + assert!(html.contains("Public Song EN"), "EN title missing"); + // Never the raw unescaped markup + assert!(!html.contains("Public "), "title not escaped"); + // The private song leaks neither name nor row + assert!(!html.contains("Top Secret Anthem"), "private name leaked"); + + // The title lookup itself only answers for public songs + assert_eq!(database::public_song_title(public_id, false), Some(String::from("Public & \"Co\""))); + assert_eq!(database::public_song_title(public_id, true), Some(String::from("Public Song EN"))); + assert_eq!(database::public_song_title(private_id, false), None); + assert_eq!(database::public_song_title(private_id + 5000, false), None); + } + // The JSON clear-rate endpoint filters non-public custom songs per requesting // user: the owner sees all of theirs, a shared user sees the songs shared with // them, everyone else sees only public ones. Official (stock) live ids are