mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
Compare commits
3 Commits
b4cd6968d8
...
582d56c301
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
582d56c301 | ||
|
|
0b0bef7bfb | ||
|
|
29ed7a1b46 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ docker/data/
|
|||||||
ndk/
|
ndk/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
custom_songs/
|
custom_songs/
|
||||||
|
custom_cards/
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ pub mod card;
|
|||||||
pub mod shop;
|
pub mod shop;
|
||||||
pub mod custom_song;
|
pub mod custom_song;
|
||||||
pub mod custom_card;
|
pub mod custom_card;
|
||||||
|
pub mod rich_text;
|
||||||
pub mod webui;
|
pub mod webui;
|
||||||
pub mod clear_rate;
|
pub mod clear_rate;
|
||||||
pub mod exchange;
|
pub mod exchange;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
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::databases::csv::{table, Region};
|
||||||
use crate::router::custom_song::audio;
|
use crate::router::custom_song::audio;
|
||||||
use crate::database::custom_card as database;
|
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()
|
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 let Some(bytes) = file_of(fields, &base) {
|
||||||
if bytes.len() > MAX_VOICE_BYTES {
|
if bytes.len() > MAX_VOICE_BYTES {
|
||||||
return Err(format!("'{}' exceeds the {} MB per-file limit for voicelines", base, MAX_VOICE_BYTES / (1024 * 1024)));
|
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
|
// 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<JsonValue, String> {
|
fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
|
||||||
for (key, label) in [("name", "Card name"), ("name_en", "Card English name")] {
|
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));
|
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");
|
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", "Skill description"),
|
||||||
("skill_detail_text_en", "Skill English 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));
|
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");
|
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", "Character gacha display name"),
|
||||||
("character_name_richtext_gacha_en", "Character English 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));
|
return Err(format!("{} is required", label));
|
||||||
}
|
}
|
||||||
|
// The gacha display name is the ONE column official data formats, and only ever with
|
||||||
|
// <size=NN> (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"] {
|
for key in ["character_image_color", "character_image_color_dark"] {
|
||||||
if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) {
|
if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) {
|
||||||
@@ -1656,6 +1683,49 @@ pub mod tests {
|
|||||||
rv
|
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 <size=NN>
|
||||||
|
#[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, "<size=400%>x")).unwrap_err();
|
||||||
|
assert!(error.contains("<size>"), "{} -> {}", key, error);
|
||||||
|
}
|
||||||
|
// Profile columns too
|
||||||
|
assert!(run(&mutated("character_height", "<b>170cm</b>")).unwrap_err().contains("<b>"));
|
||||||
|
assert!(run(&mutated("character_voice_actor", "<sprite=2>")).unwrap_err().contains("<sprite>"));
|
||||||
|
// Descriptions wrap; nothing else
|
||||||
|
assert!(run(&mutated("character_detail_text", "<rotate=45>x")).unwrap_err().contains("<rotate>"));
|
||||||
|
// The gacha display name keeps <size>, still nothing else
|
||||||
|
assert!(run(&mutated("character_name_richtext_gacha", "<sprite=1>")).unwrap_err().contains("<sprite>"));
|
||||||
|
|
||||||
|
// 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", "<color=red>x");
|
||||||
|
assert!(run(&fields).unwrap_err().contains("<color>"));
|
||||||
|
|
||||||
|
// The official shape uploads: <size=NN> on the gacha name, <br> in a description
|
||||||
|
let mut fields = character_fields();
|
||||||
|
field(&mut fields, "character_name_richtext_gacha", "<size=80>Test</size>");
|
||||||
|
field(&mut fields, "character_detail_text", "line one<br>line two");
|
||||||
|
let id = run(&fields).unwrap();
|
||||||
|
let character = database::get_character(id).unwrap();
|
||||||
|
assert_eq!(character["name_richtext_gacha"].as_str(), Some("<size=80>Test</size>"));
|
||||||
|
assert_eq!(character["detail_text"].as_str(), Some("line one<br>line two"));
|
||||||
|
}
|
||||||
|
|
||||||
// Voicelines: transcode to ogg, wire shape + captions, renumbering,
|
// Voicelines: transcode to ogg, wire shape + captions, renumbering,
|
||||||
// caption-only edits, replacement GC, deletion, the caps, and the
|
// caption-only edits, replacement GC, deletion, the caps, and the
|
||||||
// content-addressed voice route index
|
// 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_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"));
|
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, "<size=400%>x")).unwrap_err();
|
||||||
|
assert!(error.contains("<size>"), "{} -> {}", key, error);
|
||||||
|
}
|
||||||
|
assert!(run(&mutated("skill_detail_text", "<sprite=1>")).unwrap_err().contains("<sprite>"));
|
||||||
|
// 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 -
|
// Enum ranges - each one is a client crash, not a cosmetic error -
|
||||||
// and every message states the actual allowed range
|
// 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", "0")).unwrap_err().contains("type must be 1-4"));
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
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::database::custom_song as database;
|
||||||
use crate::runtime::get_data_path;
|
use crate::runtime::get_data_path;
|
||||||
use crate::lock_onto_mutex;
|
use crate::lock_onto_mutex;
|
||||||
@@ -102,9 +102,21 @@ async fn list(Login(key): Login) -> impl Responder {
|
|||||||
return Api(None);
|
return Api(None);
|
||||||
}
|
}
|
||||||
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
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!{
|
Api(Some(object!{
|
||||||
"revision": database::get_revision(),
|
"revision": database::get_revision(),
|
||||||
"songs": database::get_songs_for_user(uid)
|
"songs": songs
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,15 +319,20 @@ fn process_jacket(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), String> {
|
|||||||
Ok((jacket_png, blur_png))
|
Ok((jacket_png, blur_png))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cue_json(cue: &audio::Cue, cue_name: String) -> JsonValue {
|
// is_loop follows the official cue convention, and it is load-bearing: the client's CriWare
|
||||||
|
// layer reports a LOOP cue as forever-playing, so Playback.IsPlayEnd() never turns true for it.
|
||||||
|
// The live's end trigger (LiveTimeController.UpdateFree: isMusicEnded -> InLiveDelay -> EndWait)
|
||||||
|
// hangs off exactly that signal, so a looping PLAY cue means the live never ends. Only the
|
||||||
|
// music-select PREVIEW cue loops, like the official select bgm.
|
||||||
|
fn cue_json(cue: &audio::Cue, cue_name: String, is_loop: bool) -> JsonValue {
|
||||||
object!{
|
object!{
|
||||||
"cue_name": cue_name,
|
"cue_name": cue_name,
|
||||||
"md5": cue.md5.clone(),
|
"md5": cue.md5.clone(),
|
||||||
"size": cue.bytes.len(),
|
"size": cue.bytes.len(),
|
||||||
"duration_sec": cue.duration_sec as f32,
|
"duration_sec": cue.duration_sec as f32,
|
||||||
"is_loop": true,
|
"is_loop": is_loop,
|
||||||
"loop_start_sec": 0.0,
|
"loop_start_sec": 0.0,
|
||||||
"loop_end_sec": cue.duration_sec as f32
|
"loop_end_sec": if is_loop { cue.duration_sec as f32 } else { 0.0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,20 +343,93 @@ fn asset_meta(bytes: &[u8]) -> (String, usize) {
|
|||||||
(format!("{:x}", md5::compute(bytes)), bytes.len())
|
(format!("{:x}", md5::compute(bytes)), bytes.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score thresholds when the uploader doesn't provide any: take the highest
|
// Score rank thresholds. These are NOT per-song in SIF2: all 637 rows of the official live
|
||||||
// difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10)
|
// masterdata carry the exact same C/B/A/S tuple, solo and multi alike (live.csv columns
|
||||||
// (~200 points per note, scaled up for harder charts), then C/B/A/S at
|
// _scoreC.._multiScoreS), because the score depends on deck strength rather than chart size.
|
||||||
// 50%/75%/100%/130% of base. Multi live thresholds are 1.2x the solo ones.
|
// Deriving them from the chart's note count instead made rank S trivial on a short custom
|
||||||
fn default_scores(full_combo: i64, level_number: i64) -> (JsonValue, JsonValue) {
|
// chart and unreachable on a long one, and skewed everything else that reads them - the live
|
||||||
let base = full_combo as f64 * 200.0 * (1.0 + level_number as f64 / 10.0);
|
// score gauge and the deck-confirm score estimation both scale off _scoreS/_multiScoreS
|
||||||
let score = |mult: f64| (base * mult) as u32;
|
// (LiveData.MaxScore = _scoreS * 5 / 4).
|
||||||
|
const OFFICIAL_SCORE: [i64; 4] = [20000, 100000, 250000, 350000];
|
||||||
|
const OFFICIAL_MULTI_SCORE: [i64; 4] = [70000, 350000, 875000, 1225000];
|
||||||
|
|
||||||
|
fn default_scores() -> (JsonValue, JsonValue) {
|
||||||
(object!{
|
(object!{
|
||||||
"c": score(0.5), "b": score(0.75), "a": score(1.0), "s": score(1.3)
|
"c": OFFICIAL_SCORE[0], "b": OFFICIAL_SCORE[1], "a": OFFICIAL_SCORE[2], "s": OFFICIAL_SCORE[3]
|
||||||
}, object!{
|
}, object!{
|
||||||
"c": score(0.5 * 1.2), "b": score(0.75 * 1.2), "a": score(1.0 * 1.2), "s": score(1.3 * 1.2)
|
"c": OFFICIAL_MULTI_SCORE[0], "b": OFFICIAL_MULTI_SCORE[1], "a": OFFICIAL_MULTI_SCORE[2], "s": OFFICIAL_MULTI_SCORE[3]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 - <br> 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).
|
||||||
|
// The previous 25/50/75/100% spread made the fourth mission demand a literal FULL COMBO of
|
||||||
|
// the hardest chart, a target no official song ever sets.
|
||||||
|
fn mission_combo(hardest_combo: i64) -> JsonValue {
|
||||||
|
let target = |fraction: f64| (hardest_combo as f64 * fraction + 0.5) as i64;
|
||||||
|
jzon::array![target(0.2), target(0.4), target(0.6), target(0.8)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// The live's count-in, and the longest a marker can be in flight before its note. The count-in
|
||||||
|
// is LiveMst._startWait, 2.0 in every official live row and in ours. The flight time is
|
||||||
|
// LiveUtils.GetMarkerMoveTime(speed) = 1.725 - 0.125 * speed, clamped at 0.1, where speed is the
|
||||||
|
// player's per-difficulty rhythm-icon setting; its slowest end (and even a hypothetical 0) stays
|
||||||
|
// under the count-in, so a note at t >= 0 always has room to travel. Kept explicit so the check
|
||||||
|
// below stays honest if either constant ever moves.
|
||||||
|
const START_WAIT_SEC: f64 = 2.0;
|
||||||
|
const MAX_MARKER_MOVE_SEC: f64 = 1.725;
|
||||||
|
|
||||||
|
// A chart has to fit INSIDE its audio, at both ends.
|
||||||
|
//
|
||||||
|
// Tail: the live ends the moment the audio does - LiveTimeController's m_MusicDuration is
|
||||||
|
// LiveMst._endWait + the music length and _endWait is 0 - so a note whose MISS window closes
|
||||||
|
// after that is never judged. The player cannot full-combo the chart (the combo missions and
|
||||||
|
// the FULL COMBO banner both compare against full_combo, which counts every note), and the
|
||||||
|
// trailing markers are still on screen when the result screen takes over.
|
||||||
|
//
|
||||||
|
// Head: a marker spawns at time - GetMarkerMoveTime and the chart clock starts at -_startWait,
|
||||||
|
// so a note earlier than the flight time minus the count-in would pop in already halfway down
|
||||||
|
// the lane.
|
||||||
|
fn validate_chart_fits_audio(level: i64, chart: &JsonValue, duration_sec: f64) -> Result<(), String> {
|
||||||
|
let end = chart::end_time(chart);
|
||||||
|
if end > duration_sec {
|
||||||
|
return Err(format!(
|
||||||
|
"Difficulty {}: the chart needs {:.2}s but the audio is only {:.2}s long - the last note is never judged, because the live ends when the music does",
|
||||||
|
level, end, duration_sec
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(first) = chart::first_note_time(chart) {
|
||||||
|
let earliest = MAX_MARKER_MOVE_SEC - START_WAIT_SEC;
|
||||||
|
if first < earliest {
|
||||||
|
return Err(format!(
|
||||||
|
"Difficulty {}: the first note is at {:.2}s, before the {:.2}s the live needs to bring a marker down the lane",
|
||||||
|
level, first, earliest
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, String> {
|
fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, String> {
|
||||||
let name = field_str(fields, "name");
|
let name = field_str(fields, "name");
|
||||||
let artist = field_str(fields, "artist");
|
let artist = field_str(fields, "artist");
|
||||||
@@ -360,6 +450,15 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
return Err(format!("Unknown band category '{}'", band_category));
|
return Err(format!("Unknown band category '{}'", band_category));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validate_song_text(
|
||||||
|
&name,
|
||||||
|
&field_str(fields, "name_en"),
|
||||||
|
&field_str(fields, "short_name"),
|
||||||
|
&field_str(fields, "kana"),
|
||||||
|
&artist,
|
||||||
|
&field_str(fields, "artist_en")
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut visibility = field_str(fields, "visibility");
|
let mut visibility = field_str(fields, "visibility");
|
||||||
if visibility.is_empty() {
|
if visibility.is_empty() {
|
||||||
visibility = String::from("public");
|
visibility = String::from("public");
|
||||||
@@ -398,6 +497,10 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
.ok_or(String::from("An audio track is required"))?;
|
.ok_or(String::from("An audio track is required"))?;
|
||||||
let (play, select) = audio::process(audio_bytes, field_f64(fields, "preview_start_sec"), field_f64(fields, "preview_length_sec"))?;
|
let (play, select) = audio::process(audio_bytes, field_f64(fields, "preview_start_sec"), field_f64(fields, "preview_length_sec"))?;
|
||||||
|
|
||||||
|
for (level, chart, _, _, _) in charts.iter() {
|
||||||
|
validate_chart_fits_audio(*level, chart, play.duration_sec)?;
|
||||||
|
}
|
||||||
|
|
||||||
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
let lock = lock_onto_mutex!(UPLOAD_LOCK);
|
||||||
let music_id = database::next_music_id();
|
let music_id = database::next_music_id();
|
||||||
|
|
||||||
@@ -421,8 +524,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
let (jacket_md5, jacket_size) = asset_meta(&jacket);
|
let (jacket_md5, jacket_size) = asset_meta(&jacket);
|
||||||
let (jacket_blur_md5, jacket_blur_size) = asset_meta(&jacket_blur);
|
let (jacket_blur_md5, jacket_blur_size) = asset_meta(&jacket_blur);
|
||||||
|
|
||||||
let (_, _, hardest_combo, hardest_stars, _) = charts.last().unwrap();
|
let (_, _, hardest_combo, _, _) = charts.last().unwrap();
|
||||||
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
|
let (score, multi_score) = default_scores();
|
||||||
|
|
||||||
// The upload metadata in the multipart-field schema, kept alongside the
|
// The upload metadata in the multipart-field schema, kept alongside the
|
||||||
// original artifacts so the song can be exported and re-uploaded elsewhere
|
// original artifacts so the song can be exported and re-uploaded elsewhere
|
||||||
@@ -465,8 +568,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
"end_wait": 0.0,
|
"end_wait": 0.0,
|
||||||
"score": score,
|
"score": score,
|
||||||
"multi_score": multi_score,
|
"multi_score": multi_score,
|
||||||
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo
|
// Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo
|
||||||
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
|
"mission_combo": mission_combo(*hardest_combo),
|
||||||
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
||||||
"jacket_md5": jacket_md5,
|
"jacket_md5": jacket_md5,
|
||||||
"jacket_size": jacket_size,
|
"jacket_size": jacket_size,
|
||||||
@@ -476,8 +579,8 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
|
|||||||
"levels": levels,
|
"levels": levels,
|
||||||
"sound": {
|
"sound": {
|
||||||
"cue_sheet": format!("song_{}_{}", music_id, suffix),
|
"cue_sheet": format!("song_{}_{}", music_id, suffix),
|
||||||
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix)),
|
"play": cue_json(&play, format!("play_{}_{}", music_id, suffix), false),
|
||||||
"select": cue_json(&select, format!("select_{}_{}", music_id, suffix))
|
"select": cue_json(&select, format!("select_{}_{}", music_id, suffix), true)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -558,6 +661,11 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
return Err(format!("Unknown band category '{}'", band_category));
|
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)
|
// (level, replacement chart json + original SIF1 bytes, full_combo, level_number)
|
||||||
let mut charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, i64)> = Vec::new();
|
let mut charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, i64)> = Vec::new();
|
||||||
let mut removed: Vec<i64> = Vec::new();
|
let mut removed: Vec<i64> = Vec::new();
|
||||||
@@ -619,6 +727,29 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Every chart in the RESULTING song has to fit the RESULTING audio, so replacing either
|
||||||
|
// side re-checks the other: new audio is validated against the charts that stay, and a new
|
||||||
|
// chart against the audio that stays (read back from the catalog's own cue metadata).
|
||||||
|
let play_duration = match &play {
|
||||||
|
Some(play) => Some(play.duration_sec),
|
||||||
|
None => old_song["sound"]["play"]["duration_sec"].as_f64()
|
||||||
|
};
|
||||||
|
if let Some(duration) = play_duration {
|
||||||
|
for (level, chart, _, _) in charts.iter() {
|
||||||
|
match chart {
|
||||||
|
Some((chart, _)) => validate_chart_fits_audio(*level, chart, duration)?,
|
||||||
|
None => {
|
||||||
|
let path = song_path(music_id, &format!("chart_{}.json", level));
|
||||||
|
if let Ok(bytes) = fs::read(&path) {
|
||||||
|
if let Ok(stored) = jzon::parse(&String::from_utf8_lossy(&bytes)) {
|
||||||
|
validate_chart_fits_audio(*level, &stored, duration)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let suffix = format!("Custom{}", music_id);
|
let suffix = format!("Custom{}", music_id);
|
||||||
let mut levels = array![];
|
let mut levels = array![];
|
||||||
for (level, chart, full_combo, level_number) in charts.iter() {
|
for (level, chart, full_combo, level_number) in charts.iter() {
|
||||||
@@ -656,8 +787,8 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
|
|
||||||
// Scores and combo missions always derive from the resulting state, with
|
// Scores and combo missions always derive from the resulting state, with
|
||||||
// the same formulas as upload
|
// the same formulas as upload
|
||||||
let (_, _, hardest_combo, hardest_stars) = charts.last().unwrap();
|
let (_, _, hardest_combo, _) = charts.last().unwrap();
|
||||||
let (score, multi_score) = default_scores(*hardest_combo, *hardest_stars);
|
let (score, multi_score) = default_scores();
|
||||||
|
|
||||||
let mut manifest_levels = array![];
|
let mut manifest_levels = array![];
|
||||||
for (level, _, _, level_number) in charts.iter() {
|
for (level, _, _, level_number) in charts.iter() {
|
||||||
@@ -685,10 +816,10 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
// Same id everywhere, so the cue sheet/cue names never change
|
// Same id everywhere, so the cue sheet/cue names never change
|
||||||
let mut sound = old_song["sound"].clone();
|
let mut sound = old_song["sound"].clone();
|
||||||
if let Some(play) = &play {
|
if let Some(play) = &play {
|
||||||
sound["play"] = cue_json(play, format!("play_{}_{}", music_id, suffix));
|
sound["play"] = cue_json(play, format!("play_{}_{}", music_id, suffix), false);
|
||||||
}
|
}
|
||||||
if let Some(select) = &select {
|
if let Some(select) = &select {
|
||||||
sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix));
|
sound["select"] = cue_json(select, format!("select_{}_{}", music_id, suffix), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
let song = object!{
|
let song = object!{
|
||||||
@@ -707,8 +838,8 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
|
|||||||
"end_wait": 0.0,
|
"end_wait": 0.0,
|
||||||
"score": score,
|
"score": score,
|
||||||
"multi_score": multi_score,
|
"multi_score": multi_score,
|
||||||
// Combo missions at 25/50/75/100% of the hardest difficulty's full combo
|
// Combo missions at the official 20/40/60/80% of the hardest difficulty's full combo
|
||||||
"mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo],
|
"mission_combo": mission_combo(*hardest_combo),
|
||||||
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
"jacket": format!("/custom_song/assets/{}/jacket.png", music_id),
|
||||||
"jacket_md5": jacket_md5,
|
"jacket_md5": jacket_md5,
|
||||||
"jacket_size": jacket_size,
|
"jacket_size": jacket_size,
|
||||||
@@ -1029,17 +1160,19 @@ mod tests {
|
|||||||
|
|
||||||
// A SIF1 chart whose transcode contains 3+ simultaneous notes: 4 parallel holds into a
|
// A SIF1 chart whose transcode contains 3+ simultaneous notes: 4 parallel holds into a
|
||||||
// full 9-lane wall (the shape of the field-reported chart that exposed the old encoding)
|
// full 9-lane wall (the shape of the field-reported chart that exposed the old encoding)
|
||||||
|
// 4 parallel holds then a 9-wide wall. Timed to fit inside the 2s test track: uploads are
|
||||||
|
// rejected when a chart outlives its audio (validate_chart_fits_audio)
|
||||||
fn wall_chart() -> Vec<u8> {
|
fn wall_chart() -> Vec<u8> {
|
||||||
let mut beatmap = jzon::array![];
|
let mut beatmap = jzon::array![];
|
||||||
for position in [2, 4, 6, 8] {
|
for position in [2, 4, 6, 8] {
|
||||||
beatmap.push(jzon::object!{
|
beatmap.push(jzon::object!{
|
||||||
"timing_sec": 1.0, "notes_attribute": 1, "notes_level": 1,
|
"timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1,
|
||||||
"effect": 3, "effect_value": 1.0, "position": position
|
"effect": 3, "effect_value": 0.5, "position": position
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
}
|
}
|
||||||
for position in 1..=9 {
|
for position in 1..=9 {
|
||||||
beatmap.push(jzon::object!{
|
beatmap.push(jzon::object!{
|
||||||
"timing_sec": 2.75, "notes_attribute": 1, "notes_level": 1,
|
"timing_sec": 1.5, "notes_attribute": 1, "notes_level": 1,
|
||||||
"effect": 1, "effect_value": 0.0, "position": position
|
"effect": 1, "effect_value": 0.0, "position": position
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1256,246 @@ mod tests {
|
|||||||
assert_eq!(database::get_revision(), revision + 1);
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The live PLAY cue must never be a loop cue: the client reports a looping playback as
|
||||||
|
// forever-playing, and the live's end trigger waits on playback-end, so a looping play cue
|
||||||
|
// means the live never ends. New uploads emit is_loop:false, the preview cue keeps looping,
|
||||||
|
// and the startup migration un-loops catalogs written before the distinction existed.
|
||||||
|
#[test]
|
||||||
|
fn play_cue_never_loops() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Loop Check");
|
||||||
|
field(&mut fields, "artist", "Loop Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
// A tone no other test uses: the audio store is content-addressed and shared across
|
||||||
|
// tests, so a duplicate cue would keep another test's cue alive past its own GC
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(880.0));
|
||||||
|
fields.insert(String::from("chart_1"), test_chart());
|
||||||
|
let music_id = create_song(5555, &fields).unwrap();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
assert_eq!(song["sound"]["play"]["is_loop"], false);
|
||||||
|
assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0);
|
||||||
|
assert_eq!(song["sound"]["select"]["is_loop"], true);
|
||||||
|
assert!(song["sound"]["select"]["loop_end_sec"].as_f64().unwrap() > 0.0);
|
||||||
|
|
||||||
|
// Doctor the catalog back to the pre-fix shape an old server would have written,
|
||||||
|
// then boot: the migration un-loops the play cue and bumps the revision once
|
||||||
|
let mut old = song.clone();
|
||||||
|
old["sound"]["play"]["is_loop"] = true.into();
|
||||||
|
old["sound"]["play"]["loop_end_sec"] = old["sound"]["play"]["duration_sec"].clone();
|
||||||
|
database::update_song(music_id, &old);
|
||||||
|
let revision = database::get_revision();
|
||||||
|
|
||||||
|
migrate::run();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
assert_eq!(song["sound"]["play"]["is_loop"], false);
|
||||||
|
assert_eq!(song["sound"]["play"]["loop_end_sec"], 0.0);
|
||||||
|
assert_eq!(song["sound"]["select"]["is_loop"], true);
|
||||||
|
// The ogg bytes never moved, so the audio md5 must not change (no re-download)
|
||||||
|
assert_eq!(song["sound"]["play"]["md5"], old["sound"]["play"]["md5"]);
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
|
||||||
|
// Idempotent
|
||||||
|
migrate::run();
|
||||||
|
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, "<size=400%>boom");
|
||||||
|
let error = create_song(8888, &fields).unwrap_err();
|
||||||
|
assert!(error.contains(label), "{} -> {}", key, error);
|
||||||
|
assert!(error.contains("<size>"), "{} -> {}", 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", "<sprite=1>");
|
||||||
|
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("<size=400%>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
|
||||||
|
// count-in always covers the marker's flight time.
|
||||||
|
#[test]
|
||||||
|
fn chart_must_fit_its_audio() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
// Taps at the given times, one per lane sweep
|
||||||
|
let chart_at = |times: &[f64]| {
|
||||||
|
let mut beatmap = jzon::array![];
|
||||||
|
for (i, time) in times.iter().enumerate() {
|
||||||
|
beatmap.push(jzon::object!{
|
||||||
|
"timing_sec": *time, "notes_attribute": 1, "notes_level": 1,
|
||||||
|
"effect": 1, "effect_value": 0.0, "position": (i % 9) + 1
|
||||||
|
}).unwrap();
|
||||||
|
}
|
||||||
|
jzon::stringify(beatmap).into_bytes()
|
||||||
|
};
|
||||||
|
let base = |chart: Vec<u8>| {
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Fit Check");
|
||||||
|
field(&mut fields, "artist", "Fit Artist");
|
||||||
|
field(&mut fields, "attribute", "1");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
// 2 seconds of audio
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(990.0));
|
||||||
|
fields.insert(String::from("chart_1"), chart);
|
||||||
|
fields
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2.5s note in a 2.0s track: the last note's MISS window closes long after the live ends
|
||||||
|
let error = create_song(6666, &base(chart_at(&[0.5, 2.5]))).unwrap_err();
|
||||||
|
assert!(error.contains("Difficulty 1"), "{}", error);
|
||||||
|
assert!(error.contains("the audio is only"), "{}", error);
|
||||||
|
// Nothing was stored
|
||||||
|
assert!(database::get_song(6666).is_none());
|
||||||
|
|
||||||
|
// Right at the edge: 1.8 + the 0.15 tap MISS window is 1.95, inside 2.0. A note at t=0
|
||||||
|
// is accepted too - the count-in covers the marker flight
|
||||||
|
let music_id = create_song(6666, &base(chart_at(&[0.0, 1.8]))).unwrap();
|
||||||
|
let before = database::get_song(music_id).unwrap();
|
||||||
|
|
||||||
|
// Editing in a chart that doesn't fit the stored audio is rejected, and the stored
|
||||||
|
// song is untouched
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert(String::from("chart_1"), chart_at(&[0.5, 3.0]));
|
||||||
|
let error = update_song(music_id, &fields).unwrap_err();
|
||||||
|
assert!(error.contains("the audio is only"), "{}", error);
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before.clone()));
|
||||||
|
|
||||||
|
// Adding a difficulty whose chart doesn't fit is rejected the same way
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert(String::from("chart_4"), chart_at(&[0.5, 5.0]));
|
||||||
|
field(&mut fields, "level_number_4", "12");
|
||||||
|
assert!(update_song(music_id, &fields).is_err());
|
||||||
|
assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before));
|
||||||
|
|
||||||
|
// A chart that DOES fit still edits in
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert(String::from("chart_1"), chart_at(&[0.25, 1.5]));
|
||||||
|
update_song(music_id, &fields).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The startup migration also corrects the two fabricated masterdata values in stored
|
||||||
|
// catalogs: score-rank thresholds (official constants, not per-song) and combo missions
|
||||||
|
// (20/40/60/80% of the hardest full combo, not 25/50/75/100%)
|
||||||
|
#[test]
|
||||||
|
fn startup_migration_fixes_fabricated_scores_and_missions() {
|
||||||
|
let _lock = crate::runtime::lock_test_data_path();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
field(&mut fields, "name", "Old Values");
|
||||||
|
field(&mut fields, "artist", "Old Artist");
|
||||||
|
field(&mut fields, "attribute", "3");
|
||||||
|
field(&mut fields, "level_number_1", "5");
|
||||||
|
fields.insert(String::from("jacket"), test_png());
|
||||||
|
fields.insert(String::from("audio"), test_ogg_tone(1100.0));
|
||||||
|
fields.insert(String::from("chart_1"), wall_chart());
|
||||||
|
let music_id = create_song(7777, &fields).unwrap();
|
||||||
|
|
||||||
|
let mut song = database::get_song(music_id).unwrap();
|
||||||
|
let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap();
|
||||||
|
// Doctor the catalog back to the pre-fix formulas
|
||||||
|
let base = hardest as f64 * 200.0 * (1.0 + 5.0 / 10.0);
|
||||||
|
song["score"] = object!{
|
||||||
|
"c": (base * 0.5) as u32, "b": (base * 0.75) as u32, "a": base as u32, "s": (base * 1.3) as u32
|
||||||
|
};
|
||||||
|
song["multi_score"] = object!{
|
||||||
|
"c": (base * 0.6) as u32, "b": (base * 0.9) as u32, "a": (base * 1.2) as u32, "s": (base * 1.56) as u32
|
||||||
|
};
|
||||||
|
song["mission_combo"] = jzon::array![hardest / 4, hardest / 2, hardest * 3 / 4, hardest];
|
||||||
|
database::update_song(music_id, &song);
|
||||||
|
let revision = database::get_revision();
|
||||||
|
|
||||||
|
migrate::run();
|
||||||
|
|
||||||
|
let song = database::get_song(music_id).unwrap();
|
||||||
|
let (score, multi_score) = default_scores();
|
||||||
|
assert_eq!(song["score"], score);
|
||||||
|
assert_eq!(song["multi_score"], multi_score);
|
||||||
|
assert_eq!(song["score"]["s"], 350000);
|
||||||
|
assert_eq!(song["multi_score"]["s"], 1225000);
|
||||||
|
// 20/40/60/80%, and never the full combo itself
|
||||||
|
assert_eq!(song["mission_combo"], mission_combo(hardest));
|
||||||
|
assert_eq!(song["mission_combo"][3].as_i64().unwrap(), (hardest as f64 * 0.8 + 0.5) as i64);
|
||||||
|
assert!(song["mission_combo"][3].as_i64().unwrap() < hardest);
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
|
||||||
|
// Idempotent
|
||||||
|
migrate::run();
|
||||||
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
}
|
||||||
|
|
||||||
// Export a song, import the package as another user, and the served song
|
// Export a song, import the package as another user, and the served song
|
||||||
// must be identical apart from the assigned music_id - INCLUDING the audio
|
// must be identical apart from the assigned music_id - INCLUDING the audio
|
||||||
// md5s: ogg uploads are stored as-is and the preview encode is
|
// md5s: ogg uploads are stored as-is and the preview encode is
|
||||||
@@ -1219,16 +1592,19 @@ mod tests {
|
|||||||
assert_eq!(song["sound"]["select"]["md5"], before["sound"]["select"]["md5"]);
|
assert_eq!(song["sound"]["select"]["md5"], before["sound"]["select"]["md5"]);
|
||||||
assert_eq!(database::get_revision(), revision + 1);
|
assert_eq!(database::get_revision(), revision + 1);
|
||||||
|
|
||||||
// Adding a difficulty re-derives scores from the new hardest chart,
|
// Adding a difficulty re-derives the combo missions from the new hardest chart, the
|
||||||
// and the manifest follows so exports reflect the edited state
|
// score thresholds stay on the official constants, and the manifest follows so
|
||||||
|
// exports reflect the edited state
|
||||||
let mut fields = HashMap::new();
|
let mut fields = HashMap::new();
|
||||||
fields.insert(String::from("chart_4"), test_chart());
|
fields.insert(String::from("chart_4"), test_chart());
|
||||||
field(&mut fields, "level_number_4", "12");
|
field(&mut fields, "level_number_4", "12");
|
||||||
update_song(music_id, &fields).unwrap();
|
update_song(music_id, &fields).unwrap();
|
||||||
let song = database::get_song(music_id).unwrap();
|
let song = database::get_song(music_id).unwrap();
|
||||||
assert_eq!(song["levels"].len(), 2);
|
assert_eq!(song["levels"].len(), 2);
|
||||||
let (score, _) = default_scores(3, 12);
|
let (score, _) = default_scores();
|
||||||
assert_eq!(song["score"]["s"], score["s"]);
|
assert_eq!(song["score"]["s"], score["s"]);
|
||||||
|
let hardest = song["levels"].members().last().unwrap()["full_combo"].as_i64().unwrap();
|
||||||
|
assert_eq!(song["mission_combo"], mission_combo(hardest));
|
||||||
assert!(fs::read(song_path(music_id, "chart_4.json")).is_ok());
|
assert!(fs::read(song_path(music_id, "chart_4.json")).is_ok());
|
||||||
assert!(fs::read(song_path(music_id, "original/chart_4.json")).is_ok());
|
assert!(fs::read(song_path(music_id, "original/chart_4.json")).is_ok());
|
||||||
let manifest = jzon::parse(&String::from_utf8_lossy(&fs::read(song_path(music_id, "original/manifest.json")).unwrap())).unwrap();
|
let manifest = jzon::parse(&String::from_utf8_lossy(&fs::read(song_path(music_id, "original/manifest.json")).unwrap())).unwrap();
|
||||||
|
|||||||
@@ -64,6 +64,71 @@ use jzon::{object, JsonValue};
|
|||||||
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
|
// - max_combo_count = all real notes EXCEPT hold heads whose tail is on the same
|
||||||
// line (the game counts a same-lane hold as one combo for the chain)
|
// line (the game counts a same-lane hold as one combo for the chain)
|
||||||
|
|
||||||
|
// Two notes are SIMULTANEOUS (one spawn cluster) when their times agree to within this.
|
||||||
|
// Uploaded timings are decimal literals, so notes an author meant to be simultaneous parse to
|
||||||
|
// bit-identical f64 - but a hold's SYNTHESIZED tail is computed (timing + effect_value), and
|
||||||
|
// e.g. 1.4 + 0.7 is 2.0999999999999996, which exact equality splits from a note literally at
|
||||||
|
// 2.1. That cost the two the shared spawn num, and with it the client's sync connector line
|
||||||
|
// (LiveTimeController.CreateMarkerTimeData pairs GroupSyncMarkerData by GroupId). The client
|
||||||
|
// itself never compares two note times - simultaneity is entirely decided here by `num`, and
|
||||||
|
// it stores time as f32 anyway - so this tolerance only has to sit above f64 accumulation
|
||||||
|
// noise (~1e-15) and below any real spacing: the tightest a chart ever uses is a 1/64 note at
|
||||||
|
// 250 BPM, ~15 ms, four orders of magnitude above this.
|
||||||
|
pub const SIMULTANEOUS_EPSILON_SEC: f64 = 1e-6;
|
||||||
|
|
||||||
|
fn simultaneous(a: f64, b: f64) -> bool {
|
||||||
|
(a - b).abs() <= SIMULTANEOUS_EPSILON_SEC
|
||||||
|
}
|
||||||
|
|
||||||
|
// MISS window, from the live_input_result masterdata BAD row (_offsetTimeSec 0.15,
|
||||||
|
// _offsetTimeSecSlider 0.34). LiveTimeController.UpdateMarkerTime destroys and force-MISSes a
|
||||||
|
// marker once the chart clock passes time + this (LiveUtils.GetMissOffsetTime), so a note is
|
||||||
|
// only judgeable while the live is still running that far past it.
|
||||||
|
const MISS_OFFSET_SEC: f64 = 0.15;
|
||||||
|
const MISS_OFFSET_SLIDER_SEC: f64 = 0.34;
|
||||||
|
|
||||||
|
// MarkerData.IsSliderMarker: a chained note with a cross-lane parent or child.
|
||||||
|
fn is_slider(data: &JsonValue, line_of: &dyn Fn(i64) -> Option<i64>) -> bool {
|
||||||
|
let parent_id = data["parent_id"].as_i64().unwrap_or(0);
|
||||||
|
let child_id = data["child_id"].as_i64().unwrap_or(0);
|
||||||
|
if parent_id == 0 && child_id == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let line = data["line"].as_i64().unwrap_or(0);
|
||||||
|
if child_id != 0 && data["child_line"].as_i64().unwrap_or(0) != line {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
parent_id != 0 && line_of(parent_id) != Some(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chart clock time at which the LAST note stops being judgeable - i.e. the moment the live
|
||||||
|
// must still be running to. The live ends when the audio does (LiveTimeController's
|
||||||
|
// m_MusicDuration is LiveMst._endWait + the music length, and _endWait is 0 in every one of the
|
||||||
|
// 637 official live rows and in ours), so this is what has to fit inside the audio.
|
||||||
|
pub fn end_time(chart: &JsonValue) -> f64 {
|
||||||
|
let lines: Vec<(i64, i64)> = chart["notes"].members().skip(1)
|
||||||
|
.map(|n| (n["id"].as_i64().unwrap_or(0), n["line"].as_i64().unwrap_or(0)))
|
||||||
|
.collect();
|
||||||
|
let line_of = |id: i64| lines.iter().find(|(i, _)| *i == id).map(|(_, line)| *line);
|
||||||
|
|
||||||
|
let mut end: f64 = 0.0;
|
||||||
|
for data in chart["notes"].members().skip(1) {
|
||||||
|
let offset = if is_slider(data, &line_of) { MISS_OFFSET_SLIDER_SEC } else { MISS_OFFSET_SEC };
|
||||||
|
end = end.max(data["time"].as_f64().unwrap_or(0.0) + offset);
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
// The earliest note in the chart, or None for an empty chart
|
||||||
|
pub fn first_note_time(chart: &JsonValue) -> Option<f64> {
|
||||||
|
chart["notes"].members().skip(1)
|
||||||
|
.filter_map(|n| n["time"].as_f64())
|
||||||
|
.fold(None, |first: Option<f64>, time| Some(match first {
|
||||||
|
Some(first) => first.min(time),
|
||||||
|
None => time
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
struct WorkNote {
|
struct WorkNote {
|
||||||
time: f64,
|
time: f64,
|
||||||
line: i64,
|
line: i64,
|
||||||
@@ -207,7 +272,7 @@ pub fn transcode(beatmap: &JsonValue) -> Result<(JsonValue, i64), String> {
|
|||||||
let mut start = 0;
|
let mut start = 0;
|
||||||
while start < order.len() {
|
while start < order.len() {
|
||||||
let mut end = start + 1;
|
let mut end = start + 1;
|
||||||
while end < order.len() && work[order[end]].time == work[order[start]].time {
|
while end < order.len() && simultaneous(work[order[end]].time, work[order[start]].time) {
|
||||||
end += 1;
|
end += 1;
|
||||||
}
|
}
|
||||||
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
||||||
@@ -307,7 +372,7 @@ pub fn regroup(chart: &mut JsonValue) -> bool {
|
|||||||
let mut start = 0;
|
let mut start = 0;
|
||||||
while start < order.len() {
|
while start < order.len() {
|
||||||
let mut end = start + 1;
|
let mut end = start + 1;
|
||||||
while end < order.len() && notes[order[end]].1 == notes[order[start]].1 {
|
while end < order.len() && simultaneous(notes[order[end]].1, notes[order[start]].1) {
|
||||||
end += 1;
|
end += 1;
|
||||||
}
|
}
|
||||||
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
let mut cluster: Vec<usize> = order[start..end].to_vec();
|
||||||
@@ -460,6 +525,57 @@ mod tests {
|
|||||||
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
|
assert_eq!(tail["time"].as_f64().unwrap(), 3.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hold tail's time is COMPUTED (timing + effect_value), so it can land a few f64 ulps off
|
||||||
|
// a note written at the same beat - 1.4 + 0.7 is 2.0999999999999996, not 2.1. Exact equality
|
||||||
|
// split those two into separate spawn groups and the client lost the sync connector line
|
||||||
|
// between them; the epsilon keeps them together. The tail must still be emitted at its own
|
||||||
|
// computed time (the client stores time as f32, which lands both on 2.1 anyway)
|
||||||
|
#[test]
|
||||||
|
fn a_computed_hold_tail_shares_the_beats_spawn_group() {
|
||||||
|
assert_ne!(1.4f64 + 0.7f64, 2.1f64);
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(1.4, 3, 3, 0.7), // hold, tail computed at 2.0999999999999996
|
||||||
|
sif_note(2.1, 7, 1, 0.0) // tap written at 2.1
|
||||||
|
];
|
||||||
|
let (chart, _) = transcode(&beatmap).unwrap();
|
||||||
|
|
||||||
|
let tail = chart["notes"].members().find(|n| n["parent_id"] != 0).unwrap();
|
||||||
|
let tap = chart["notes"].members().find(|n| n["line"] == 6).unwrap();
|
||||||
|
assert_eq!(tail["num"], tap["num"].clone());
|
||||||
|
assert!((tail["time"].as_f64().unwrap() - 2.1).abs() < 1e-9);
|
||||||
|
assert_spawn_groups_hold_at_most_two(&chart);
|
||||||
|
|
||||||
|
// Genuinely distinct beats stay distinct: the epsilon is orders of magnitude below the
|
||||||
|
// tightest spacing a chart ever uses (a 1/64 note at 250 BPM is ~15 ms)
|
||||||
|
let beatmap = jzon::array![
|
||||||
|
sif_note(2.1, 3, 1, 0.0),
|
||||||
|
sif_note(2.115, 7, 1, 0.0)
|
||||||
|
];
|
||||||
|
let (chart, _) = transcode(&beatmap).unwrap();
|
||||||
|
assert_ne!(chart["notes"][1]["num"], chart["notes"][2]["num"].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// end_time is what validate_chart_fits_audio compares against the track length: the last
|
||||||
|
// moment a note is still judgeable, using the BAD-row MISS window (0.15 tap / 0.34 slider)
|
||||||
|
#[test]
|
||||||
|
fn end_time_uses_the_miss_window_of_the_last_note() {
|
||||||
|
let (chart, _) = transcode(&jzon::array![sif_note(10.0, 3, 1, 0.0)]).unwrap();
|
||||||
|
assert!((end_time(&chart) - 10.15).abs() < 1e-9);
|
||||||
|
assert_eq!(first_note_time(&chart), Some(10.0));
|
||||||
|
|
||||||
|
// A hold: the synthesized tail is the last note, and it is same-lane so not a slider
|
||||||
|
let (chart, _) = transcode(&jzon::array![sif_note(10.0, 3, 3, 2.0)]).unwrap();
|
||||||
|
assert!((end_time(&chart) - 12.15).abs() < 1e-9);
|
||||||
|
assert_eq!(first_note_time(&chart), Some(10.0));
|
||||||
|
|
||||||
|
// A cross-lane slide run: every segment is a slider, so the wider window applies
|
||||||
|
let (chart, _) = transcode(&jzon::array![
|
||||||
|
sif_slide(10.0, 9, 11, 0.0, 5),
|
||||||
|
sif_slide(10.5, 8, 11, 0.0, 5)
|
||||||
|
]).unwrap();
|
||||||
|
assert!((end_time(&chart) - 10.84).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
// The client spawns markers one num-group at a time and CreateMarkerUI refuses lists of
|
// The client spawns markers one num-group at a time and CreateMarkerUI refuses lists of
|
||||||
// more than 2, so no num may ever be shared by 3+ notes (official charts never do)
|
// more than 2, so no num may ever be shared by 3+ notes (official charts never do)
|
||||||
fn assert_spawn_groups_hold_at_most_two(chart: &JsonValue) {
|
fn assert_spawn_groups_hold_at_most_two(chart: &JsonValue) {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ use crate::runtime::get_data_path;
|
|||||||
// the regroup needs (time + line per note), so they are rewritten in place — no original
|
// the regroup needs (time + line per note), so they are rewritten in place — no original
|
||||||
// upload required, which also covers songs from before export support.
|
// upload required, which also covers songs from before export support.
|
||||||
//
|
//
|
||||||
|
// The same pass also corrects the stored catalog values that were fabricated rather than
|
||||||
|
// derived from official masterdata (see below): the looping PLAY cue, the per-song score-rank
|
||||||
|
// thresholds and the combo-mission targets.
|
||||||
|
//
|
||||||
// For each song directory with a catalog row, every chart with an over-shared num is
|
// For each song directory with a catalog row, every chart with an over-shared num is
|
||||||
// regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog
|
// regrouped (chart::regroup), rewritten to disk, and its level's md5/size in the catalog
|
||||||
// blob updated — the changed md5 re-keys the client's content-addressed cache, so clients
|
// blob updated — the changed md5 re-keys the client's content-addressed cache, so clients
|
||||||
@@ -38,6 +42,43 @@ pub fn run() {
|
|||||||
let Some(mut song) = database::get_song(music_id) else { continue; };
|
let Some(mut song) = database::get_song(music_id) else { continue; };
|
||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Catalogs written before cue_json took an is_loop argument marked BOTH cues as loop
|
||||||
|
// cues. A looping PLAY cue never reports playback-end to the client, and the live's end
|
||||||
|
// trigger (LiveTimeController: isMusicEnded -> InLiveDelay -> EndWait) hangs off exactly
|
||||||
|
// that, so those lives never finish. Only the metadata changes - the ogg bytes and their
|
||||||
|
// md5 are untouched, so no re-download is needed, just a catalog resync.
|
||||||
|
if song["sound"]["play"]["is_loop"] == true {
|
||||||
|
song["sound"]["play"]["is_loop"] = false.into();
|
||||||
|
song["sound"]["play"]["loop_end_sec"] = 0.0.into();
|
||||||
|
changed = true;
|
||||||
|
println!("Custom song {}: play cue un-looped (pre-fix catalog)", music_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score-rank thresholds and combo-mission targets used to be invented per song. Both are
|
||||||
|
// objective masterdata: the official live rows all carry ONE score tuple, and every
|
||||||
|
// live_mission_combo row is round(hardest full combo * 0.2/0.4/0.6/0.8) (see
|
||||||
|
// default_scores / mission_combo). Recompute them the way an edit would, so songs
|
||||||
|
// uploaded before the fix stop handing out rank S for free and stop asking for a literal
|
||||||
|
// full combo on the fourth combo mission.
|
||||||
|
let (score, multi_score) = super::default_scores();
|
||||||
|
if song["score"] != score || song["multi_score"] != multi_score {
|
||||||
|
song["score"] = score;
|
||||||
|
song["multi_score"] = multi_score;
|
||||||
|
changed = true;
|
||||||
|
println!("Custom song {}: score rank thresholds reset to the official values", music_id);
|
||||||
|
}
|
||||||
|
// Same "hardest difficulty" rule as upload/edit: the last level entry, which both write
|
||||||
|
// in ascending level order
|
||||||
|
if let Some(hardest) = song["levels"].members().last().and_then(|l| l["full_combo"].as_i64()) {
|
||||||
|
let missions = super::mission_combo(hardest);
|
||||||
|
if song["mission_combo"] != missions {
|
||||||
|
song["mission_combo"] = missions;
|
||||||
|
changed = true;
|
||||||
|
println!("Custom song {}: combo missions rescaled to the official 20/40/60/80%", music_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for level in 1..=LEVEL_COUNT {
|
for level in 1..=LEVEL_COUNT {
|
||||||
let path = song_path(music_id, &format!("chart_{}.json", level));
|
let path = song_path(music_id, &format!("chart_{}.json", level));
|
||||||
let Ok(bytes) = fs::read(&path) else { continue; };
|
let Ok(bytes) = fs::read(&path) else { continue; };
|
||||||
|
|||||||
144
src/router/rich_text.rs
Normal file
144
src/router/rich_text.rs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
// TextMeshPro rich-text guarding for user-supplied strings.
|
||||||
|
//
|
||||||
|
// Every name/description the custom-song and custom-card uploads accept is rendered by the game
|
||||||
|
// through TextMeshUI -> TMP with rich text ENABLED (the shipped labels carry m_isRichText: 1),
|
||||||
|
// and the client does NOT escape it: it hands user text straight to SetText, exactly as it does
|
||||||
|
// for other players' account names. So a song called "<size=400%>x", a card whose skill
|
||||||
|
// description carries <sprite=...>, or a character named "<font=nonexistent>" mangles or breaks
|
||||||
|
// every screen that shows it - for EVERY player, since public songs and published cards are
|
||||||
|
// visible to all. This is the in-game equivalent of stored XSS.
|
||||||
|
//
|
||||||
|
// The fix belongs here rather than at the render seam, because that is where official data draws
|
||||||
|
// the line: the shipped masterdata carries NO markup in any name or artist column (1102 of the
|
||||||
|
// 1103 tags in the whole EN music table are <br>, all of them inside detailInfo), <br> in the
|
||||||
|
// descriptive columns, and <size=NN> only in character nameRichtextGacha - a column whose name
|
||||||
|
// says it is meant to be rich text. Uploads are held to exactly that shape.
|
||||||
|
//
|
||||||
|
// A '<' that TMP would not read as a tag is left alone, so titles like "<3" still upload.
|
||||||
|
|
||||||
|
// The tags found in `text`, lowercased and without a leading '/'. Mirrors TMP's own scan: a tag
|
||||||
|
// opens at '<' followed by an optional '/' then a letter or '#' (a colour tag), and must close
|
||||||
|
// with '>' before the next '<'. Anything else is literal text.
|
||||||
|
fn tags(text: &str) -> Vec<String> {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut rv = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] != '<' {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut j = i + 1;
|
||||||
|
if j < chars.len() && chars[j] == '/' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
// Not tag-like: a bare "<3", "< 3", "<<"
|
||||||
|
if j >= chars.len() || !(chars[j].is_ascii_alphabetic() || chars[j] == '#') {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name_start = j;
|
||||||
|
while j < chars.len() && chars[j] != '>' && chars[j] != '<' && chars[j] != '=' && chars[j] != ' ' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let name: String = chars[name_start..j].iter().collect();
|
||||||
|
// The tag has to actually close before another one opens, or TMP prints it verbatim
|
||||||
|
while j < chars.len() && chars[j] != '>' && chars[j] != '<' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j < chars.len() && chars[j] == '>' {
|
||||||
|
rv.push(name.to_lowercase());
|
||||||
|
i = j + 1;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject text carrying a rich-text tag the field is not supposed to have. `allowed` holds
|
||||||
|
// lowercase tag names without the slash, so listing "size" permits both <size=80> and </size>.
|
||||||
|
pub fn reject_tags(label: &str, text: &str, allowed: &[&str]) -> Result<(), String> {
|
||||||
|
for tag in tags(text) {
|
||||||
|
if !allowed.contains(&tag.as_str()) {
|
||||||
|
return Err(format!(
|
||||||
|
"{} may not contain the rich text tag <{}> - the game renders it as formatting and it would break the screens it appears on",
|
||||||
|
label, tag
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop every rich-text tag, keeping the text between them. For strings that are ALREADY stored
|
||||||
|
// and cannot be rejected at the point of use - an uploader's account name, which ew accepts
|
||||||
|
// verbatim on the profile route.
|
||||||
|
pub fn strip_tags(text: &str) -> String {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut rv = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
if chars[i] == '<' {
|
||||||
|
let mut j = i + 1;
|
||||||
|
if j < chars.len() && chars[j] == '/' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j < chars.len() && (chars[j].is_ascii_alphabetic() || chars[j] == '#') {
|
||||||
|
while j < chars.len() && chars[j] != '>' && chars[j] != '<' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if j < chars.len() && chars[j] == '>' {
|
||||||
|
i = j + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rv.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
rv
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_real_tags_are_tags() {
|
||||||
|
// Literal text TMP never reads as formatting
|
||||||
|
for text in ["I <3 you", "a < b", "3 <", "<<", "1<2", "<b unclosed", "<b never closes"] {
|
||||||
|
assert!(reject_tags("Name", text, &[]).is_ok(), "{}", text);
|
||||||
|
}
|
||||||
|
// Everything TMP would format with
|
||||||
|
for text in ["<b>x", "x</b>", "<size=400%>x", "<sprite=1>", "<#ff0000>x", "<color=red>x",
|
||||||
|
"<font=\"none\">x", "<rotate=45>x", "<noparse>x", "<voffset=5em>x",
|
||||||
|
// the unclosed opener is literal, but the tag after it is not
|
||||||
|
"<b <i>x"] {
|
||||||
|
assert!(reject_tags("Name", text, &[]).is_err(), "{}", text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allowed_tags_pass_and_others_still_dont() {
|
||||||
|
assert!(reject_tags("Description", "line one<br>line two", &["br"]).is_ok());
|
||||||
|
assert!(reject_tags("Description", "line one<br>line two", &[]).is_err());
|
||||||
|
// The slash form of an allowed tag is allowed too
|
||||||
|
assert!(reject_tags("Gacha name", "<size=80>Mari</size>", &["size"]).is_ok());
|
||||||
|
assert!(reject_tags("Gacha name", "<size=80><sprite=3>", &["size"]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_error_names_the_field_and_the_tag() {
|
||||||
|
let error = reject_tags("Song name", "<size=400%>boom", &[]).unwrap_err();
|
||||||
|
assert!(error.contains("Song name"), "{}", error);
|
||||||
|
assert!(error.contains("<size>"), "{}", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stripping_keeps_the_words_and_the_harmless_angle_brackets() {
|
||||||
|
assert_eq!(strip_tags("<size=400%>Nozomi</size>"), "Nozomi");
|
||||||
|
assert_eq!(strip_tags("I <3 <b>you</b>"), "I <3 you");
|
||||||
|
assert_eq!(strip_tags("plain name"), "plain name");
|
||||||
|
assert_eq!(strip_tags("<b unclosed"), "<b unclosed");
|
||||||
|
}
|
||||||
|
}
|
||||||
2
webui
2
webui
Submodule webui updated: 124065a313...700dae60a3
Reference in New Issue
Block a user