diff --git a/src/database/custom_song.rs b/src/database/custom_song.rs index 94f11a5..10fb551 100644 --- a/src/database/custom_song.rs +++ b/src/database/custom_song.rs @@ -274,3 +274,28 @@ pub fn dead_music_ids(candidates: &JsonValue) -> JsonValue { pub fn audio_in_use(md5: &str, ignored_music_id: i64) -> bool { DATABASE.lock_and_select("SELECT music_id FROM songs WHERE music_id!=?1 AND song LIKE ?2", params!(ignored_music_id, format!("%{}%", md5))).is_ok() } + +// Resolve a content-addressed chart/jacket md5 to the per-song-id file that +// currently holds those bytes: (music_id, relative filename under +// custom_songs/{music_id}/). Charts and jackets live in the per-song directory +// (not a shared md5 store), and their catalog md5 always tracks the on-disk +// bytes - so this is the index the /custom_song/data/{md5} route serves from, +// and it self-heals: an edited asset gets a new md5 and old md5s stop resolving. +// Audio has its own route and is intentionally not resolved here. +pub fn find_asset_by_md5(md5: &str) -> Option<(i64, String)> { + let blob = DATABASE.lock_and_select("SELECT song FROM songs WHERE song LIKE ?1", params!(format!("%{}%", md5))).ok()?; + let song = jzon::parse(&blob).ok()?; + let music_id = song["music_id"].as_i64()?; + if song["jacket_md5"].as_str() == Some(md5) { + return Some((music_id, String::from("jacket.png"))); + } + if song["jacket_blur_md5"].as_str() == Some(md5) { + return Some((music_id, String::from("jacket_blur.png"))); + } + for level in song["levels"].members() { + if level["md5"].as_str() == Some(md5) { + return Some((music_id, format!("chart_{}.json", level["level"].as_i64()?))); + } + } + None +} diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index 4017301..c04cdc7 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -61,6 +61,7 @@ pub fn web_routes(cfg: &mut web::ServiceConfig) { web::scope("/custom_song") .route("/assets/{music_id}/{file}", web::get().to(assets)) .route("/audio/{hash}/{file}", web::get().to(audio)) + .route("/data/{hash}/{file}", web::get().to(data)) .route("/upload", web::post().to(upload)) .route("/update", web::post().to(update)) .route("/mine", web::get().to(mine)) @@ -169,6 +170,36 @@ async fn audio(req: HttpRequest) -> HttpResponse { } } +// Content-addressed chart/jacket fetch, same '{server}/{hash}/{name}.{ext}' +// shape as the audio route. The game client builds the URL from the md5 it +// reads in the catalog (charts -> .json, jackets -> .png); the ext is cosmetic, +// the md5 resolves the bytes. Visible-to-all like the other asset routes (CDN +// semantics) - only the feature flag gates it. A changed asset has a new md5, +// so a stale md5 simply 404s and the client re-downloads under the new one. +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((music_id, filename)) = database::find_asset_by_md5(&hash) else { + return HttpResponse::NotFound().finish(); + }; + match fs::read(song_path(music_id, &filename)) { + Ok(body) => { + let mime = mime_guess::from_path(&filename).first_or_octet_stream(); + HttpResponse::Ok() + .insert_header(ContentType(mime)) + .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)?; @@ -266,6 +297,13 @@ fn cue_json(cue: &audio::Cue, cue_name: String) -> JsonValue { } } +// (md5-hex, byte-length) of a downloadable asset's served bytes. The client +// caches charts/jackets content-addressed by this md5, so it must be the hash +// of the exact bytes the data route returns +fn asset_meta(bytes: &[u8]) -> (String, usize) { + (format!("{:x}", md5::compute(bytes)), bytes.len()) +} + // Score thresholds when the uploader doesn't provide any: take the highest // difficulty's full_combo and stars, budget base = full_combo * 200 * (1 + stars / 10) // (~200 points per note, scaled up for harder charts), then C/B/A/S at @@ -343,7 +381,9 @@ fn create_song(uid: i64, fields: &HashMap>) -> Result>) -> Result>) -> Result>) -> Result<(), S let suffix = format!("Custom{}", music_id); let mut levels = array![]; - for (level, _, full_combo, level_number) in charts.iter() { + for (level, chart, full_combo, level_number) in charts.iter() { + // md5/size track the on-disk bytes: a replaced chart hashes its new + // bytes, an unchanged one re-hashes the existing file (so even a song + // edited before md5 fields existed gets a complete, correct catalog) + let (md5, size) = match chart { + Some((chart, _)) => asset_meta(jzon::stringify(chart.clone()).as_bytes()), + None => asset_meta(&fs::read(song_path(music_id, &format!("chart_{}.json", level))).map_err(|e| e.to_string())?) + }; levels.push(object!{ "level": *level, "level_number": *level_number, @@ -558,9 +614,23 @@ fn update_song(music_id: i64, fields: &HashMap>) -> Result<(), S "score_coeff": 1.0, // Official convention: the filename difficulty index is level+1 "note_data_file_name": format!("{}_{}_{}", music_id, level + 1, suffix), - "chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level) + "chart": format!("/custom_song/assets/{}/chart_{}.json", music_id, level), + "md5": md5, + "size": size }).unwrap(); } + let (jacket_md5, jacket_size, jacket_blur_md5, jacket_blur_size) = match &jacket { + Some((jacket, blur)) => { + let (jm, js) = asset_meta(jacket); + let (bm, bs) = asset_meta(blur); + (jm, js, bm, bs) + }, + None => { + let (jm, js) = asset_meta(&fs::read(song_path(music_id, "jacket.png")).map_err(|e| e.to_string())?); + let (bm, bs) = asset_meta(&fs::read(song_path(music_id, "jacket_blur.png")).map_err(|e| e.to_string())?); + (jm, js, bm, bs) + } + }; // Scores and combo missions always derive from the resulting state, with // the same formulas as upload @@ -618,6 +688,11 @@ fn update_song(music_id: i64, fields: &HashMap>) -> Result<(), S // Combo missions at 25/50/75/100% of the hardest difficulty's full combo "mission_combo": [hardest_combo / 4, hardest_combo / 2, hardest_combo * 3 / 4, *hardest_combo], "jacket": format!("/custom_song/assets/{}/jacket.png", music_id), + "jacket_md5": jacket_md5, + "jacket_size": jacket_size, + "jacket_blur": format!("/custom_song/assets/{}/jacket_blur.png", music_id), + "jacket_blur_md5": jacket_blur_md5, + "jacket_blur_size": jacket_blur_size, "levels": levels, "sound": sound }; @@ -1299,4 +1374,89 @@ mod tests { assert!(!client_supports_custom_songs(&wrong)); assert!(appended(&wrong).is_empty()); } + + // Catalog chart/jacket entries carry md5+size, and the content-addressed + // data route serves the exact bytes for a known md5 (404 for unknown / off) + #[test] + fn catalog_md5_and_data_route() { + use actix_web::test::TestRequest; + let _lock = crate::runtime::lock_test_data_path(); + + let mut fields = HashMap::new(); + field(&mut fields, "name", "Data Route"); + field(&mut fields, "artist", "A"); + field(&mut fields, "attribute", "1"); + fields.insert(String::from("jacket"), test_png()); + fields.insert(String::from("audio"), test_ogg()); + fields.insert(String::from("chart_1"), test_chart()); + let music_id = create_song(9191, &fields).unwrap(); + + let song = database::get_song(music_id).unwrap(); + let chart_md5 = song["levels"][0]["md5"].to_string(); + let jacket_md5 = song["jacket_md5"].to_string(); + let blur_md5 = song["jacket_blur_md5"].to_string(); + // Every downloadable asset entry carries a 32-hex md5 and a nonzero size + for (md5, size) in [ + (&chart_md5, song["levels"][0]["size"].as_usize()), + (&jacket_md5, song["jacket_size"].as_usize()), + (&blur_md5, song["jacket_blur_size"].as_usize()) + ] { + assert_eq!(md5.len(), 32, "md5 {}", md5); + assert!(md5.chars().all(|c| c.is_ascii_hexdigit())); + assert!(size.unwrap() > 0); + } + // Audio cues still carry md5+size (unchanged) + assert_eq!(song["sound"]["play"]["md5"].to_string().len(), 32); + assert!(song["sound"]["select"]["size"].as_usize().unwrap() > 0); + + let call = |hash: &str, ext: &str| -> HttpResponse { + let req = TestRequest::default() + .param("hash", hash.to_string()) + .param("file", format!("{}.{}", hash, ext)) + .to_http_request(); + actix_web::rt::System::new().block_on(async { data(req).await }) + }; + let body_of = |resp: HttpResponse| -> Vec { + actix_web::rt::System::new() + .block_on(async { actix_web::body::to_bytes(resp.into_body()).await.unwrap() }) + .to_vec() + }; + + // The md5 route serves the exact bytes whose hash IS that md5 + let resp = call(&chart_md5, "json"); + assert_eq!(resp.status(), actix_web::http::StatusCode::OK); + let body = body_of(resp); + assert_eq!(format!("{:x}", md5::compute(&body)), chart_md5); + assert_eq!(body.len(), song["levels"][0]["size"].as_usize().unwrap()); + + let resp = call(&jacket_md5, "png"); + assert_eq!(resp.status(), actix_web::http::StatusCode::OK); + assert_eq!(format!("{:x}", md5::compute(body_of(resp))), jacket_md5); + + let resp = call(&blur_md5, "png"); + assert_eq!(resp.status(), actix_web::http::StatusCode::OK); + assert_eq!(format!("{:x}", md5::compute(body_of(resp))), blur_md5); + + // Unknown md5 -> 404 + assert_eq!(call(&"0".repeat(32), "json").status(), actix_web::http::StatusCode::NOT_FOUND); + // Malformed hash -> 404 + assert_eq!(call("nothex", "json").status(), actix_web::http::StatusCode::NOT_FOUND); + + // Feature disabled -> 404 even for a real md5 + crate::runtime::set_enable_custom_songs(false); + assert_eq!(call(&chart_md5, "json").status(), actix_web::http::StatusCode::NOT_FOUND); + crate::runtime::set_enable_custom_songs(true); + + // Editing the chart changes its md5 (self-heal): old md5 stops resolving + let mut edit = HashMap::new(); + edit.insert(String::from("chart_1"), jzon::stringify(jzon::array![ + {"timing_sec": 0.5, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 4}, + {"timing_sec": 1.2, "notes_attribute": 1, "notes_level": 1, "effect": 1, "effect_value": 0.0, "position": 8} + ]).into_bytes()); + update_song(music_id, &edit).unwrap(); + let new_md5 = database::get_song(music_id).unwrap()["levels"][0]["md5"].to_string(); + assert_ne!(new_md5, chart_md5); + assert_eq!(call(&chart_md5, "json").status(), actix_web::http::StatusCode::NOT_FOUND); + assert_eq!(call(&new_md5, "json").status(), actix_web::http::StatusCode::OK); + } }