From d975b87799e514a038c7dd7e8dedbfa3ef41ccd4 Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Sat, 15 Aug 2026 22:20:14 -0500 Subject: [PATCH] Oh --- .gitignore | 1 + docker/docker-compose.yml | 1 + docker/start.sh | 1 + src/database.rs | 1 + src/database/announcements.rs | 20 +- src/database/custom_3dmv.rs | 217 +++++ src/database/custom_song.rs | 8 + src/database/permissions.rs | 16 +- src/lib.rs | 1 + src/options.rs | 3 + src/router.rs | 4 + src/router/custom_3dmv.rs | 1504 +++++++++++++++++++++++++++++ src/router/custom_3dmv/package.rs | 88 ++ src/router/custom_3dmv/vmd.rs | 121 +++ src/router/custom_song.rs | 14 + src/router/webui.rs | 18 + src/runtime.rs | 13 +- webui | 2 +- 18 files changed, 2015 insertions(+), 18 deletions(-) create mode 100644 src/database/custom_3dmv.rs create mode 100644 src/router/custom_3dmv.rs create mode 100644 src/router/custom_3dmv/package.rs create mode 100644 src/router/custom_3dmv/vmd.rs diff --git a/.gitignore b/.gitignore index bf26214..8e5eb3d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ ndk/ .DS_Store custom_songs/ custom_cards/ +/custom_3dmv/ # local-only trees — never commit (35GB between them) /android/ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d5350b3..3803a41 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,7 @@ services: DISABLE_EXPORTS: false # Will disable account exports #ENABLE_CUSTOM_SONGS: true # Custom songs are DISABLED by default; uncomment to enable upload/browse/download (webui + endpoints) #ENABLE_CUSTOM_CARDS: true # Custom cards are DISABLED by default; uncomment to enable runtime card/character uploads (webui + endpoints) + #ENABLE_CUSTOM_3DMV: true # Custom 3D MVs are DISABLED by default; uncomment to enable MMD model+motion MV uploads for custom songs (webui + endpoints) #OWNER: "123456789012345" # 15-digit game user id(s) (comma-separated) that hold every permission scope; without an owner nobody can grant scopes or upload cards #PURGE: false # Purge dead user accounts on startup #IMAGE_ASSET_PATH: /images/ # Images for cards in webui (will default to the public server) diff --git a/docker/start.sh b/docker/start.sh index 84822ee..8d74931 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -15,6 +15,7 @@ args=( [ "${DISABLE_EXPORTS:-}" = "true" ] && args+=(--disable-exports) [ "${ENABLE_CUSTOM_SONGS:-}" = "true" ] && args+=(--enable-custom-songs) [ "${ENABLE_CUSTOM_CARDS:-}" = "true" ] && args+=(--enable-custom-cards) +[ "${ENABLE_CUSTOM_3DMV:-}" = "true" ] && args+=(--enable-custom-3dmv) add_opt() { local value="$1" flag="$2" diff --git a/src/database.rs b/src/database.rs index 97817a2..ed8de97 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,5 +1,6 @@ pub mod gree; pub mod custom_song; pub mod custom_card; +pub mod custom_3dmv; pub mod permissions; pub mod announcements; diff --git a/src/database/announcements.rs b/src/database/announcements.rs index 3669553..60b601a 100644 --- a/src/database/announcements.rs +++ b/src/database/announcements.rs @@ -183,8 +183,8 @@ mod tests { // has_banner reflects the blob without carrying it assert_eq!(cat1[0]["has_banner"].as_bool(), Some(true)); assert_eq!(cat1[1]["has_banner"].as_bool(), Some(false)); - assert_eq!(banner(b), Some(vec![1, 2, 3])); - assert_eq!(banner(a), None); + assert_eq!(get_banner(b), Some(vec![1, 2, 3])); + assert_eq!(get_banner(a), None); // The other tab is untouched, the admin view sees the draft too assert_eq!(list_category(2).len(), 1); @@ -216,12 +216,12 @@ mod tests { assert_eq!(row["updated"].as_bool(), Some(true)); assert_eq!(row["published_at"].as_i64(), Some(5000)); // Keep left the blob in place - assert_eq!(banner(id), Some(vec![9])); + assert_eq!(get_banner(id), Some(vec![9])); update(id, 3, "maintenance", "New title", "new body", Banner::Set(vec![4, 5]), true, true, 5000); - assert_eq!(banner(id), Some(vec![4, 5])); + assert_eq!(get_banner(id), Some(vec![4, 5])); update(id, 3, "maintenance", "New title", "new body", Banner::Clear, true, false, 5000); - assert_eq!(banner(id), None); + assert_eq!(get_banner(id), None); assert_eq!(get(id).unwrap()["visible"].as_bool(), Some(false)); delete(id); @@ -239,15 +239,15 @@ mod tests { let published = create(1, "news", "Live", "body", Some(vec![1, 2]), false, true, 1000, 42); let draft = create(1, "news", "Unannounced", "body", Some(vec![7, 7]), false, false, 2000, 42); - assert_eq!(banner(draft), Some(vec![7, 7])); - assert_eq!(visible_banner(draft), None); - assert_eq!(visible_banner(published), Some(vec![1, 2])); + assert_eq!(get_banner(draft), Some(vec![7, 7])); + assert_eq!(get_public_banner(draft), None); + assert_eq!(get_public_banner(published), Some(vec![1, 2])); // Publishing it makes the banner reachable, hiding it again takes it back update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, true, 2000); - assert_eq!(visible_banner(draft), Some(vec![7, 7])); + assert_eq!(get_public_banner(draft), Some(vec![7, 7])); update(draft, 1, "news", "Unannounced", "body", Banner::Keep, false, false, 2000); - assert_eq!(visible_banner(draft), None); + assert_eq!(get_public_banner(draft), None); wipe(); } diff --git a/src/database/custom_3dmv.rs b/src/database/custom_3dmv.rs new file mode 100644 index 0000000..a5379bd --- /dev/null +++ b/src/database/custom_3dmv.rs @@ -0,0 +1,217 @@ +use lazy_static::lazy_static; +use rusqlite::params; +use jzon::{array, JsonValue}; + +use crate::sql::SQLite; + +lazy_static! { + static ref DATABASE: SQLite = SQLite::new("custom_3dmv.db", setup_tables); +} + +// mv_id is its own namespace: it never appears where a music_id or a card id +// could, so the band only has to avoid colliding with itself. Ids are never +// reused after a delete (high-water mark below), so a client's cached copy of +// a dead id can't get confused with a later upload +pub const FIRST_MV_ID: i64 = 20001; +pub const LAST_MV_ID: i64 = 99_999; + +// One JSON blob per MV, in the exact shape /api/custom_3dmv/list serves - +// except `published`, which lives in its own column (the catalog filter +// queries it) and is only injected for the webui manage view +fn setup_tables(conn: &rusqlite::Connection) { + conn.execute_batch(" +CREATE TABLE IF NOT EXISTS mvs ( + mv_id BIGINT NOT NULL PRIMARY KEY, + music_id BIGINT NOT NULL, + owner_id BIGINT NOT NULL, + mv TEXT NOT NULL, + published INT NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS revision ( + id INT NOT NULL PRIMARY KEY, + revision BIGINT NOT NULL, + last_mv_id BIGINT NOT NULL +); + ").unwrap(); +} + +pub fn get_revision() -> i64 { + DATABASE.lock_and_select("SELECT revision FROM revision WHERE id=1", params!()).unwrap_or_default().parse::().unwrap_or(0) +} + +// Bumped on every upload/update/delete/publish change so the client can tell +// its cached catalog is stale +pub fn bump_revision() { + DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 1, 0) ON CONFLICT(id) DO UPDATE SET revision=revision+1", params!()); +} + +// last_mv_id is the high-water mark and only ever rises, so MAX() over the +// live rows is a floor, not the answer +pub fn next_mv_id() -> i64 { + let issued = DATABASE.lock_and_select("SELECT last_mv_id FROM revision WHERE id=1", params!()).unwrap_or_default().parse::().unwrap_or(0); + let max = DATABASE.lock_and_select("SELECT MAX(mv_id) FROM mvs", params!()).unwrap_or_default().parse::().unwrap_or(0); + std::cmp::max(std::cmp::max(issued, max), FIRST_MV_ID - 1) + 1 +} + +pub fn insert_mv(mv_id: i64, music_id: i64, owner_id: i64, mv: &JsonValue, published: bool) { + DATABASE.lock_and_exec( + "INSERT INTO mvs (mv_id, music_id, owner_id, mv, published) VALUES (?1, ?2, ?3, ?4, ?5)", + params!(mv_id, music_id, owner_id, jzon::stringify(mv.clone()), published as i64) + ); + DATABASE.lock_and_exec("INSERT INTO revision (id, revision, last_mv_id) VALUES (1, 0, ?1) ON CONFLICT(id) DO UPDATE SET last_mv_id=?1", params!(mv_id)); +} + +// The catalog blob only. The owner and the published flag live in their own +// columns and are untouched here; music_id is fixed for the life of the MV +pub fn update_mv(mv_id: i64, mv: &JsonValue) { + DATABASE.lock_and_exec("UPDATE mvs SET mv=?1 WHERE mv_id=?2", params!(jzon::stringify(mv.clone()), mv_id)); +} + +pub fn delete_mv(mv_id: i64) { + DATABASE.lock_and_exec("DELETE FROM mvs WHERE mv_id=?1", params!(mv_id)); +} + +pub fn get_mv(mv_id: i64) -> Option { + let mv = DATABASE.lock_and_select("SELECT mv FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?; + jzon::parse(&mv).ok() +} + +pub fn get_mv_owner(mv_id: i64) -> Option { + DATABASE.lock_and_select("SELECT owner_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::().ok() +} + +pub fn get_mv_music_id(mv_id: i64) -> Option { + DATABASE.lock_and_select("SELECT music_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::().ok() +} + +pub fn is_published(mv_id: i64) -> bool { + DATABASE.lock_and_select("SELECT published FROM mvs WHERE mv_id=?1", params!(mv_id)).unwrap_or_default() == "1" +} + +pub fn set_published(mv_id: i64, published: bool) { + DATABASE.lock_and_exec("UPDATE mvs SET published=?1 WHERE mv_id=?2", params!(published as i64, mv_id)); +} + +pub fn mv_count_for_owner(owner_id: i64) -> i64 { + DATABASE.lock_and_select_type::("SELECT COUNT(*) FROM mvs WHERE owner_id=?1", params!(owner_id)).unwrap_or(0) +} + +fn parse_blobs(rows: JsonValue) -> JsonValue { + let mut rv = array![]; + for data in rows.members() { + if let Ok(parsed) = jzon::parse(&data.to_string()) { + rv.push(parsed).unwrap(); + } + } + rv +} + +// The MV catalog this user is served: every published MV plus their own +// drafts, filtered against `music_ids` - the music ids the SAME user's +// custom-song catalog delivers. The closure is what keeps the response +// referentially sound: a served MV must never name a music_id the song +// catalog failed to deliver (a published MV for someone else's private song +// stays invisible) +pub fn get_mvs_for_user(user_id: i64, music_ids: &[i64]) -> JsonValue { + let rows = parse_blobs(DATABASE.lock_and_select_all( + "SELECT mv FROM mvs WHERE published=1 OR owner_id=?1 ORDER BY mv_id", + params!(user_id) + ).unwrap_or(array![])); + let mut rv = array![]; + for mv in rows.members() { + if music_ids.contains(&mv["music_id"].as_i64().unwrap_or(0)) { + rv.push(mv.clone()).unwrap(); + } + } + rv +} + +// MV blobs plus the flag column, for the webui manage view +pub fn get_mvs_by_owner(owner_id: i64) -> JsonValue { + let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE owner_id=?1 ORDER BY mv_id", params!(owner_id)).unwrap_or(array![])); + let mut rv = array![]; + for mv in rows.members() { + let mut mv = mv.clone(); + mv["published"] = is_published(mv["mv_id"].as_i64().unwrap_or(0)).into(); + rv.push(mv).unwrap(); + } + rv +} + +// The webui MV browser: published MVs whose song the viewer can see, plus the +// owner id so the page can label the uploader +pub fn get_browse_mvs(viewer_music_ids: &[i64]) -> JsonValue { + let rows = parse_blobs(DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE published=1 ORDER BY mv_id", params!()).unwrap_or(array![])); + let mut rv = array![]; + for mv in rows.members() { + if !viewer_music_ids.contains(&mv["music_id"].as_i64().unwrap_or(0)) { + continue; + } + let mut mv = mv.clone(); + mv["owner_id"] = get_mv_owner(mv["mv_id"].as_i64().unwrap_or(0)).unwrap_or(0).into(); + rv.push(mv).unwrap(); + } + rv +} + +// Every MV attached to a song, for the delete cascade +pub fn mv_ids_for_music(music_id: i64) -> Vec { + let rows = DATABASE.lock_and_select_all("SELECT mv_id FROM mvs WHERE music_id=?1 ORDER BY mv_id", params!(music_id)).unwrap_or(array![]); + rows.members().filter_map(|id| id.as_i64()).collect() +} + +// Which of these candidate ids no longer exist. Only the MV band is ever +// considered and ids are never reused, so a wipe is final. An MV that's +// merely unpublished still has its row - only genuinely deleted ids return +pub fn dead_mv_ids(candidates: &JsonValue) -> JsonValue { + let mut ids: Vec = Vec::new(); + for id in candidates.members() { + let Some(id) = id.as_i64() else { continue; }; + if (FIRST_MV_ID..=LAST_MV_ID).contains(&id) && !ids.contains(&id) { + ids.push(id); + } + } + if ids.is_empty() { + return array![]; + } + let list = ids.iter().map(|id| id.to_string()).collect::>().join(","); + let alive = DATABASE.lock_and_select_all(&format!("SELECT mv_id FROM mvs WHERE mv_id IN ({})", list), params!()).unwrap_or(array![]); + let mut rv = array![]; + for id in ids { + if !alive.contains(id) { + rv.push(id).unwrap(); + } + } + rv +} + +// Every stored catalog blob, unparsed and unfiltered. Only the startup blob +// sweep needs the whole table, and a read failure must never look like an +// empty catalog (the sweep would then delete every blob), so this returns +// None rather than an empty array on error +pub fn all_mv_blobs() -> Option { + DATABASE.lock_and_select_all("SELECT mv FROM mvs ORDER BY mv_id", params!()).ok() +} + +// Blobs are content-addressed and may be shared between MVs (or roles), so +// every candidate row is checked - a single-row scan could land on a +// coincidental substring match and miss the real reference +pub fn blob_in_use(md5: &str) -> bool { + let rows = DATABASE.lock_and_select_all("SELECT mv FROM mvs WHERE mv LIKE ?1", params!(format!("%{}%", md5))).unwrap_or(array![]); + for blob in rows.members() { + if let Ok(mv) = jzon::parse(&blob.to_string()) { + if mv["files"].members().any(|file| file["md5"].as_str() == Some(md5)) { + return true; + } + } + } + false +} + +// Two-step content-addressed lookup for the data route: the LIKE scan finds a +// candidate row cheaply, then the files array confirms the md5 is really a +// stored file of a live MV (and not a substring coincidence elsewhere in the +// blob). The blob path itself derives from the md5 +pub fn find_blob_by_md5(md5: &str) -> bool { + blob_in_use(md5) +} diff --git a/src/database/custom_song.rs b/src/database/custom_song.rs index 96b0c96..20db5f5 100644 --- a/src/database/custom_song.rs +++ b/src/database/custom_song.rs @@ -257,6 +257,14 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option { Some(if english && !name_en.is_empty() { name_en } else { name }) } +// Whether the song exists and is publicly visible - what lets another +// uploader attach cross-feature content (a custom 3D MV) to it. The +// existence check comes first: get_visibility defaults to "public" for an +// absent row +pub fn song_publicly_visible(music_id: i64) -> bool { + get_song_owner(music_id).is_some() && get_visibility(music_id) == "public" +} + 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/database/permissions.rs b/src/database/permissions.rs index 7299f4a..e34161c 100644 --- a/src/database/permissions.rs +++ b/src/database/permissions.rs @@ -23,11 +23,17 @@ pub const PERMISSION_REVOKE: &str = "permission.revoke"; pub const ANNOUNCEMENT: &str = "announcement"; pub const ANNOUNCEMENT_MANAGE: &str = "announcement.manage"; +// Uploading/publishing your own MVs needs no scope (like custom songs); +// 3dmv.edit is moderation over anybody's +pub const MV: &str = "3dmv"; +pub const MV_EDIT: &str = "3dmv.edit"; + pub const SCOPES: &[&str] = &[ ALL, CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE, - ANNOUNCEMENT, ANNOUNCEMENT_MANAGE + ANNOUNCEMENT, ANNOUNCEMENT_MANAGE, + MV, MV_EDIT ]; @@ -231,7 +237,7 @@ mod tests { for scope in SCOPES { assert!(!has(105, scope), "scope {}", scope); } - assert!(scopes_for(105).is_empty()); + assert!(get_user_permissions(105).is_empty()); assert!(!has(0, ALL)); assert!(!has(-1, ALL)); wipe(105); @@ -297,8 +303,8 @@ mod tests { assert!(has(118, scope), "scope {}", scope); assert!(has(120, scope), "scope {}", scope); } - assert_eq!(scopes_for(118).len(), 1); - assert_eq!(scopes_for(118)[0].to_string(), String::from(ALL)); + assert_eq!(get_user_permissions(118).len(), 1); + assert_eq!(get_user_permissions(118)[0].to_string(), String::from(ALL)); assert!(get_permissions(118).is_empty()); // An owner can bootstrap-grant, and can't be revoked grant(119, ALL, 118).unwrap(); @@ -319,7 +325,7 @@ mod tests { assert!(!scope.is_empty()); assert!(!scope.ends_with('.')); } - for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT_MANAGE] { + for scope in [CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT_MANAGE, MV_EDIT] { assert!(SCOPES.contains(&scope), "scope {}", scope); } } diff --git a/src/lib.rs b/src/lib.rs index 954d5f8..2533459 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,7 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> { router::custom_song::migrate::run(); router::custom_song::sweep_audio(); + router::custom_3dmv::sweep_blobs(); // The multi-live relay's expiry timers, on the system arbiter rather than on whichever // HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not // be able to strand every room's seats for the life of the process. diff --git a/src/options.rs b/src/options.rs index 484ad1f..7a6b8be 100644 --- a/src/options.rs +++ b/src/options.rs @@ -44,6 +44,9 @@ pub struct Args { #[arg(long, default_value_t = false, help = "Enable the custom cards feature (upload/manage runtime cards and characters). Disabled by default; every custom-cards endpoint and webui element is hidden unless this is set")] pub enable_custom_cards: bool, + #[arg(long, default_value_t = false, help = "Enable the custom 3D MV feature (upload/manage MMD model+motion MVs for custom songs). Disabled by default; every custom-3dmv endpoint and webui element is hidden unless this is set")] + pub enable_custom_3dmv: bool, + #[arg(long, value_delimiter = ',', help = "User id(s) of the server owner(s), repeatable or comma separated. Owner accounts implicitly hold every permission scope and are the only ones able to grant scopes on a fresh install")] pub owner: Vec, diff --git a/src/router.rs b/src/router.rs index 6e7a471..f6679c2 100644 --- a/src/router.rs +++ b/src/router.rs @@ -23,6 +23,7 @@ pub mod card; pub mod shop; pub mod custom_song; pub mod custom_card; +pub mod custom_3dmv; pub mod rich_text; pub mod webui; pub mod clear_rate; @@ -239,6 +240,7 @@ pub async fn request(req: HttpRequest, body: String) -> HttpResponse { "/api/webui/listCharacters" => webui::list_characters(req), "/api/webui/listSkillCenters" => webui::list_skill_centers(req), "/api/webui/customCardLimits" => webui::custom_card_limits(req), + "/api/webui/custom3dmvLimits" => webui::custom_3dmv_limits(req), "/api/webui/myScopes" => webui::my_scopes(req), _ => api_req(req, body).await } @@ -263,6 +265,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { .configure(chat::routes) .configure(custom_song::routes) .configure(custom_card::routes) + .configure(custom_3dmv::routes) .configure(debug::routes) .configure(event::routes) .configure(exchange::routes) @@ -291,5 +294,6 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { ); cfg.configure(custom_song::web_routes); cfg.configure(custom_card::web_routes); + cfg.configure(custom_3dmv::web_routes); cfg.configure(web::routes); } diff --git a/src/router/custom_3dmv.rs b/src/router/custom_3dmv.rs new file mode 100644 index 0000000..275f331 --- /dev/null +++ b/src/router/custom_3dmv.rs @@ -0,0 +1,1504 @@ +mod package; +pub mod vmd; + +use jzon::{array, object, JsonValue}; +use actix_web::{web, HttpRequest, HttpResponse, Responder, http::header::ContentType}; +use actix_multipart::Multipart; +use futures_util::TryStreamExt; +use lazy_static::lazy_static; +use std::collections::HashMap; +use std::fs; +use std::io::{Cursor, Read}; +use std::sync::Mutex; + +use crate::router::{global, rich_text, userdata, webui, Login, Api}; +use crate::router::custom_song; +use crate::database::custom_3dmv as database; +use crate::database::permissions; +use crate::runtime::get_data_path; +use crate::lock_onto_mutex; + +// Runtime-uploaded 3D MVs (MMD PMX models + VMD motions) attached to custom +// songs. The client fetches the catalog from /api/custom_3dmv/list at login +// and drives its MMD live director from the blobs; the server stores every +// file content-addressed and serves it verbatim - it validates structure at +// upload time and owns none of the animation semantics. +// +// MVs are owned by their uploader and draft by default: a draft is served to +// its owner's catalog only, publishing puts it in everyone's. Every catalog +// is additionally filtered by referential closure against the SAME user's +// custom-song catalog: an MV whose music_id the song catalog didn't deliver +// is never served (a published MV for someone else's private song stays +// invisible). Filtering is at the CATALOG level; the data GET is +// content-addressed and sessionless, like a CDN. +// +// Storage layout (under --path): +// custom_3dmv/blobs/{md5}.bin every model/stage zip, vmd, config - shared +// Metadata lives in custom_3dmv.db as one JSON blob per MV, in the exact +// shape /api/custom_3dmv/list serves. + +// Level 1 = custom songs, 2 = the baked SIF1 card band, 3 = runtime custom +// cards, 4 = multi-live, 5 = custom 3D MVs +pub const PROTOCOL_VERSION: u32 = 5; + +// Upload limits, enforced while the multipart field is still streaming (the +// 25MB PayloadConfig in lib.rs binds the String/Bytes extractors, not +// Multipart). Motion VMDs run 20-100MB, hence the larger caps than cards +pub const MAX_FILE_BYTES: usize = 64 * 1024 * 1024; +pub const MAX_REQUEST_BYTES: usize = 256 * 1024 * 1024; +pub const MAX_MVS_PER_USER: i64 = 200; + +// Slots are 1-based, matching the Live3dMemberMst position convention +pub const MAX_MEMBER_COUNT: i64 = 12; + +// The in-game stage scenes a config's "stage" may select, verbatim scene +// names. The first entry is the default the client falls back to when no +// config names one (or names one it doesn't recognize) - rejecting unknown +// names at upload instead gives the author feedback while the typo is fixable +pub const STAGES: &[&str] = &[ + "bg0007_02_s1", "bg0008_01_s1", "bg0014_01_s1", "bg0037_01_s1", "bg0018_02_s1", + "bg0005_01_s1", "bg0007_03_s1", "bg0003_01_s1", "bg0004_01_s1", "bg0018_01_s1", + "bg0001_01_s1", "bg0015_01_s1", "bg0027_02_s1", "bg0011_01_s1", "bg0023_01_s1", + "bg0031_01_s1", "bg0007_01_s1", "bg0017_01_s1", "bg0013_01_s1", "bg0019_01_s1", + "bg0016_01_s1", "bg0009_01_s1", "bg0007_04_s1", "bg0002_01_s1", "bg0027_01_s1", + "bg0022_01_s1", "bg0020_01_s1", "bg0032_01_s1", "bg0010_01_s1", "bg0028_01_s1", + "bg0018_03_s1", "bg0026_02_s1", "bg0029_01_s1", "bg0020_02_s1", "bg0026_01_s1", + "bg0024_01_s1", "bg0036_01_s1", "bg0012_01_s1", "bg0025_07_s1", "bg0025_11_s1", + "bg0025_01_s1", "bg0025_06_s1", "bg0006_01_s1", "bg0006_02_s1", "bg0025_02_s1", + "bg0025_03_s1", "bg0021_01_s1", "bg0025_10_s1", "bg0030_02_s1", "bg0006_03_s1", + "bg0025_09_s1", "bg0006_04_s1", "bg0007_10_s1", "bg0008_10_s1", "bg0006_10_s1", + "bg0025_08_s1", "bg0030_01_s1", "bg0025_04_s1", "bg0025_05_s1", "bg0033_01_s1", + "bg0025_12_s1", "bg0019_02_s1", "bg0038_01_s1" +]; + +type Fields = HashMap>; + +lazy_static! { + // Id allocation, blob writes and the insert must not race between two + // uploads (and the delete-side GC must not race an insert) + static ref UPLOAD_LOCK: Mutex<()> = Mutex::new(()); +} + +// Game endpoints (/api scope, standard envelope) +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/custom_3dmv") + .route("/list", web::post().to(list)) + ); +} + +// Plain blob GET for the game + session-authenticated management API for the +// webui. Mounted OUTSIDE /api so the game middlewares never wrap it +pub fn web_routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/custom_3dmv") + .route("/data/{hash}/{file}", web::get().to(data)) + .route("/upload", web::post().to(upload)) + .route("/update", web::post().to(update)) + .route("/publish", web::post().to(publish)) + .route("/delete", web::post().to(delete)) + .route("/mine", web::get().to(mine)) + .route("/browse", web::get().to(browse)) + .route("/download/{mv_id}", web::get().to(download)) + ); +} + +// The whole feature is opt-in (--enable-custom-3dmv) and additionally off in +// --hidden mode. When disabled every endpoint 404s / errors as if it never +// existed and nothing touches custom_3dmv.db (so no table setup runs) +pub fn disabled() -> bool { + let args = crate::get_args(); + args.hidden || !args.enable_custom_3dmv +} + +pub fn blob_path(md5: &str) -> String { + get_data_path(&format!("custom_3dmv/blobs/{}.bin", md5)) +} + +// The multipart field name a stored files[] entry came from, which is also +// its name inside an export package +pub fn field_key(file: &JsonValue) -> Option { + let role = file["role"].as_str()?; + match file["slot"].as_i64() { + Some(slot) => Some(format!("{}_{}", role, slot)), + None => Some(role.to_string()) + } +} + +// The music ids this user's song catalog delivers - the closure set every MV +// catalog is filtered against. Empty when custom songs are disabled, which +// correctly serves no MVs at all: there is nothing they could play over +fn allowed_music_ids(uid: i64) -> Vec { + custom_song::get_music_ids(uid).members().filter_map(|id| id.as_i64()).collect() +} + +pub fn catalog_for_user(uid: i64) -> JsonValue { + database::get_mvs_for_user(uid, &allowed_music_ids(uid)) +} + +// The catalog is filtered per requesting user: everyone gets the published +// MVs, the owner additionally gets their drafts, both closed over the same +// user's song catalog. Old clients get Api(None), feature-off semantics +async fn list(req: HttpRequest, Login(key): Login) -> impl Responder { + if disabled() { + // As if the endpoint doesn't exist - the client treats this as feature-off + return Api(None); + } + if global::client_protocol_version(&req) < PROTOCOL_VERSION { + return Api(None); + } + let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); + Api(Some(object!{ + "revision": database::get_revision(), + "mvs": catalog_for_user(uid) + })) +} + +// Content-addressed blob fetch: '{server}/custom_3dmv/data/{md5}/{md5}.bin'. +// The game builds the URL from the md5 it read in the catalog and caches by +// it, so a stale md5 simply 404s and the client re-downloads under the new +// one. Visible to all like the other custom data routes (CDN semantics) - +// only the feature flag gates it +async fn data(req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let hash = req.match_info().get("hash").unwrap_or("").to_string(); + let file = req.match_info().get("file").unwrap_or("").to_string(); + if hash.len() != 32 || !hash.chars().all(|c| c.is_ascii_hexdigit()) || !file.starts_with(&format!("{}.", hash)) { + return HttpResponse::NotFound().finish(); + } + if !database::find_blob_by_md5(&hash) { + return HttpResponse::NotFound().finish(); + } + match fs::read(blob_path(&hash)) { + Ok(body) => { + HttpResponse::Ok() + .insert_header(ContentType::octet_stream()) + .insert_header(("content-length", body.len())) + .body(body) + }, + Err(_) => HttpResponse::NotFound().finish() + } +} + +fn get_session_uid(req: &HttpRequest) -> Option { + let token = webui::get_login_token(req)?; + let login_token = userdata::webui_login_token(&token)?; + userdata::get_acc(&login_token)["user"]["id"].as_i64() +} + +fn send_json(resp: JsonValue) -> HttpResponse { + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + +// The per-file cap is enforced while the field is still streaming, BEFORE any +// byte reaches the zip/vmd parsers. The per-request cap is checked over the +// running total +async fn read_multipart(mut payload: Multipart) -> Result { + let mut fields = Fields::new(); + let mut total = 0usize; + while let Some(mut field) = payload.try_next().await.map_err(|e| e.to_string())? { + let name = field.name().unwrap_or("").to_string(); + let mut data = Vec::new(); + while let Some(chunk) = field.try_next().await.map_err(|e| e.to_string())? { + total += chunk.len(); + if total > MAX_REQUEST_BYTES { + return Err(format!("Upload exceeds the {} MB per-request limit", MAX_REQUEST_BYTES / (1024 * 1024))); + } + data.extend_from_slice(&chunk); + if data.len() > MAX_FILE_BYTES { + return Err(format!("'{}' exceeds the {} MB per-file limit", name, MAX_FILE_BYTES / (1024 * 1024))); + } + } + fields.insert(name, data); + } + Ok(fields) +} + +fn field_str(fields: &Fields, key: &str) -> String { + String::from_utf8_lossy(fields.get(key).map(|v| v.as_slice()).unwrap_or(&[])).trim().to_string() +} + +// Checkbox-style flag: "1", "true" or "on" +fn field_flag(fields: &Fields, key: &str) -> bool { + matches!(field_str(fields, key).to_lowercase().as_str(), "1" | "true" | "on") +} + +fn file_of<'a>(fields: &'a Fields, key: &str) -> Option<&'a Vec> { + fields.get(key).filter(|v| !v.is_empty()) +} + +// Partial-edit semantics for update: a field present in the form replaces the +// stored value, an absent one keeps it. On create `stored` is empty, so every +// absent field simply reads as empty/invalid and fails its own validation +fn text_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> String { + if fields.contains_key(key) { + field_str(fields, key) + } else { + stored[stored_key].as_str().unwrap_or("").to_string() + } +} + +fn number_of(fields: &Fields, key: &str, stored: &JsonValue, stored_key: &str) -> i64 { + if fields.contains_key(key) { + field_str(fields, key).parse::().unwrap_or(i64::MIN) + } else { + stored[stored_key].as_i64().unwrap_or(i64::MIN) + } +} + +// The PMX header walk from just past the version to the vertex count: the +// count-prefixed globals block, four length-prefixed text fields (model name +// and comment, JP + EN), then the i32 vertex count. None on any truncation +// or nonsense length +fn read_pmx_vertex_count(file: &mut impl Read) -> Option { + let mut count = [0u8; 1]; + file.read_exact(&mut count).ok()?; + let mut globals = vec![0u8; count[0] as usize]; + file.read_exact(&mut globals).ok()?; + for _ in 0..4 { + let mut len = [0u8; 4]; + file.read_exact(&mut len).ok()?; + let len = i32::from_le_bytes(len); + if len < 0 { + return None; + } + if std::io::copy(&mut file.by_ref().take(len as u64), &mut std::io::sink()).ok()? != len as u64 { + return None; + } + } + let mut vertices = [0u8; 4]; + file.read_exact(&mut vertices).ok()?; + Some(i32::from_le_bytes(vertices)) +} + +// A model upload is a zip carrying at least one .pmx entry. Only the header +// is read from each entry's stream: 4-byte magic "PMX " and the f32 version, +// which must be 2.0 or 2.1 (the versions the client's parser speaks). For a +// custom stage the walk continues to the vertex count - a zero-vertex stage +// renders as nothing, so it is rejected while the author can still fix it +fn validate_model_zip(label: &str, bytes: &[u8], require_vertices: bool) -> Result<(), String> { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)) + .map_err(|_| format!("'{}' is not a valid zip file", label))?; + let mut found = false; + for i in 0..archive.len() { + let mut file = archive.by_index(i).map_err(|e| format!("'{}': {}", label, e))?; + let name = file.name().to_string(); + if !name.to_lowercase().ends_with(".pmx") { + continue; + } + let mut header = [0u8; 8]; + file.read_exact(&mut header) + .map_err(|_| format!("'{}': entry '{}' is too short to be a PMX model", label, name))?; + if &header[..4] != b"PMX " { + return Err(format!("'{}': entry '{}' is missing the \"PMX \" magic", label, name)); + } + let version = f32::from_le_bytes(header[4..8].try_into().unwrap()); + if (version - 2.0).abs() > 0.001 && (version - 2.1).abs() > 0.001 { + return Err(format!("'{}': entry '{}' is PMX version {} - only 2.0 and 2.1 are supported", label, name, version)); + } + if require_vertices { + let vertices = read_pmx_vertex_count(&mut file) + .ok_or(format!("'{}': entry '{}' has a malformed PMX header", label, name))?; + if vertices <= 0 { + return Err(format!("'{}': entry '{}' has no vertices - a stage model needs geometry", label, name)); + } + } + found = true; + } + if !found { + return Err(format!("'{}' contains no .pmx model entry", label)); + } + Ok(()) +} + +// The config is otherwise opaque driver knobs with a client-defined schema, +// but "stage" is worth validating server-side: the client silently falls back +// to the default stage for a name it doesn't know +fn validate_config(label: &str, bytes: &[u8]) -> Result<(), String> { + let config = jzon::parse(&String::from_utf8_lossy(bytes)).map_err(|_| format!("'{}' is not valid JSON", label))?; + if !config["stage"].is_null() { + match config["stage"].as_str() { + Some(stage) if STAGES.contains(&stage) => {}, + _ => return Err(format!("'{}': unknown stage '{}' - it must be one of the in-game stage scene names the limits endpoint lists", label, config["stage"])) + } + } + // The custom-stage world scale the client applies (its default is 0.08, + // clamped to this same range) + if !config["stage_scale"].is_null() { + match config["stage_scale"].as_f64() { + Some(scale) if (0.005..=1.0).contains(&scale) => {}, + _ => return Err(format!("'{}': stage_scale must be a number between 0.005 and 1, not {}", label, config["stage_scale"])) + } + } + Ok(()) +} + +fn validate_file(role: &str, label: &str, bytes: &[u8]) -> Result<(), String> { + match role { + "model" => validate_model_zip(label, bytes, false), + // A custom stage overrides the config's "stage" scene client-side + "stage" => validate_model_zip(label, bytes, true), + "config" => validate_config(label, bytes), + _ => vmd::validate(label, bytes) + } +} + +struct PendingBlob { + md5: String, + bytes: Vec +} + +// One (role, slot) resolved against the form and the stored files: a new file +// replaces (validated first), a `{key}_delete` flag drops an optional role, +// an absent field keeps the stored entry. The entry's md5 is the hash of the +// exact bytes the data route serves +fn resolve_file( + fields: &Fields, stored: &JsonValue, role: &str, slot: Option, required: bool, + pending: &mut Vec +) -> Result, String> { + let key = match slot { + Some(slot) => format!("{}_{}", role, slot), + None => role.to_string() + }; + let file = file_of(fields, &key); + if field_flag(fields, &format!("{}_delete", key)) { + if file.is_some() { + return Err(format!("'{}': cannot both replace and delete the same file", key)); + } + if required { + return Err(format!("'{}' cannot be deleted - every member slot needs a model and a motion", key)); + } + return Ok(None); + } + if let Some(bytes) = file { + validate_file(role, &key, bytes)?; + let md5 = format!("{:x}", md5::compute(bytes)); + let mut entry = object!{ "role": role }; + if let Some(slot) = slot { + entry["slot"] = slot.into(); + } + entry["md5"] = md5.clone().into(); + entry["size"] = bytes.len().into(); + pending.push(PendingBlob { md5, bytes: bytes.clone() }); + return Ok(Some(entry)); + } + let kept = stored.members().find(|f| f["role"] == role && slot.map_or(true, |slot| f["slot"] == slot)); + if let Some(kept) = kept { + return Ok(Some(kept.clone())); + } + if required { + return Err(format!("'{}' is required - every member slot needs a model and a motion", key)); + } + Ok(None) +} + +// The resulting files array for `member_count` slots. Every slot 1..count +// must end up with a model and a motion; facial (per slot) and the slot-less +// camera, config and stage are optional. A member_count decrease simply stops +// visiting the higher slots, whose stored entries drop out (and their blobs GC) +fn collect_files(fields: &Fields, member_count: i64, stored: &JsonValue) -> Result<(JsonValue, Vec), String> { + let mut entries = array![]; + let mut pending = Vec::new(); + for slot in 1..=member_count { + for (role, required) in [("model", true), ("motion", true), ("facial", false)] { + if let Some(entry) = resolve_file(fields, stored, role, Some(slot), required, &mut pending)? { + entries.push(entry).unwrap(); + } + } + } + for role in ["camera", "config", "stage"] { + if let Some(entry) = resolve_file(fields, stored, role, None, false, &mut pending)? { + entries.push(entry).unwrap(); + } + } + Ok((entries, pending)) +} + +fn write_blobs(pending: &[PendingBlob]) -> Result<(), String> { + if pending.is_empty() { + return Ok(()); + } + fs::create_dir_all(get_data_path("custom_3dmv/blobs")).map_err(|e| e.to_string())?; + for blob in pending { + fs::write(blob_path(&blob.md5), &blob.bytes).map_err(|e| e.to_string())?; + } + Ok(()) +} + +// Blobs are content-addressed and may be shared between MVs and roles, so a +// file is only unlinked when no live row references its md5 anymore. Called +// under UPLOAD_LOCK after the db row changed, so the row's own surviving +// references still protect their blobs +fn gc_blobs(old_files: &JsonValue) { + for file in old_files.members() { + let md5 = file["md5"].to_string(); + if md5.len() == 32 && !database::blob_in_use(&md5) { + let _ = fs::remove_file(blob_path(&md5)); + } + } +} + +// Like custom songs, MVs are permissionless beyond login: any logged-in user +// manages (and publishes) their own. 3dmv.edit is moderation over anybody's +fn can_manage(uid: i64, owner: i64) -> bool { + owner == uid || permissions::has(uid, permissions::MV_EDIT) +} + +fn validate_names(name: &str, name_en: &str) -> Result<(), String> { + if name.is_empty() { + return Err(String::from("MV name is required")); + } + // Rendered through TMP with rich text on and no escaping, like every + // other custom-content name (rich_text.rs) + rich_text::reject_tags("MV name", name, &[])?; + rich_text::reject_tags("MV English name", name_en, &[]) +} + +pub fn create_mv(uid: i64, fields: &Fields) -> Result { + if database::mv_count_for_owner(uid) >= MAX_MVS_PER_USER { + return Err(format!("You have reached the {} MV limit", MAX_MVS_PER_USER)); + } + let published = field_flag(fields, "published"); + + let name = field_str(fields, "name"); + let name_en = field_str(fields, "name_en"); + validate_names(&name, &name_en)?; + + let music_id = field_str(fields, "music_id").parse::().unwrap_or(0); + custom_song::can_reference_song(uid, music_id)?; + + let member_count = field_str(fields, "member_count").parse::().unwrap_or(0); + if !(1..=MAX_MEMBER_COUNT).contains(&member_count) { + return Err(format!("member_count must be 1-{}", MAX_MEMBER_COUNT)); + } + + let (files, pending) = collect_files(fields, member_count, &array![])?; + + let lock = lock_onto_mutex!(UPLOAD_LOCK); + let mv_id = database::next_mv_id(); + if mv_id > database::LAST_MV_ID { + return Err(String::from("The custom MV id space is exhausted")); + } + + let mv = object!{ + "mv_id": mv_id, + "music_id": music_id, + "name": name, + "name_en": name_en, + "member_count": member_count, + "files": files + }; + + write_blobs(&pending)?; + database::insert_mv(mv_id, music_id, uid, &mv, published); + database::bump_revision(); + drop(lock); + + Ok(mv_id) +} + +// Edit an MV in place. The mv_id and the music_id stay the same: repointing +// the song would break the catalog closure for everyone who already resolved +// it (delete + re-upload retires the id instead) +pub fn update_mv(uid: i64, mv_id: i64, fields: &Fields) -> Result<(), String> { + let Some(owner) = database::get_mv_owner(mv_id) else { + return Err(String::from("MV not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You can only manage your own MVs")); + } + let stored = database::get_mv(mv_id).ok_or(String::from("MV not found"))?; + + let name = text_of(fields, "name", &stored, "name"); + let name_en = text_of(fields, "name_en", &stored, "name_en"); + validate_names(&name, &name_en)?; + + let member_count = number_of(fields, "member_count", &stored, "member_count"); + if !(1..=MAX_MEMBER_COUNT).contains(&member_count) { + return Err(format!("member_count must be 1-{}", MAX_MEMBER_COUNT)); + } + + let (files, pending) = collect_files(fields, member_count, &stored["files"])?; + + let mv = object!{ + "mv_id": mv_id, + "music_id": stored["music_id"].clone(), + "name": name, + "name_en": name_en, + "member_count": member_count, + "files": files + }; + + let lock = lock_onto_mutex!(UPLOAD_LOCK); + write_blobs(&pending)?; + database::update_mv(mv_id, &mv); + database::bump_revision(); + // Replaced/dropped files: the updated row no longer references them + gc_blobs(&stored["files"]); + drop(lock); + + Ok(()) +} + +pub fn set_mv_flags(uid: i64, mv_id: i64, published: bool) -> Result<(), String> { + let Some(owner) = database::get_mv_owner(mv_id) else { + return Err(String::from("MV not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You can only manage your own MVs")); + } + database::set_published(mv_id, published); + database::bump_revision(); + Ok(()) +} + +// Deleting retires the id forever (the high-water mark never reissues it) +pub fn delete_mv(uid: i64, mv_id: i64) -> Result<(), String> { + let Some(owner) = database::get_mv_owner(mv_id) else { + return Err(String::from("MV not found")); + }; + if !can_manage(uid, owner) { + return Err(String::from("You can only manage your own MVs")); + } + let lock = lock_onto_mutex!(UPLOAD_LOCK); + let stored = database::get_mv(mv_id); + database::delete_mv(mv_id); + database::bump_revision(); + if let Some(stored) = stored { + gc_blobs(&stored["files"]); + } + drop(lock); + Ok(()) +} + +// The delete cascade: an MV can't outlive the song it plays over. Called from +// custom_song's delete handler (which holds ITS upload lock - a different +// mutex, and nothing ever takes the two in the reverse order) +pub fn purge_song(music_id: i64) { + if disabled() { + return; + } + let lock = lock_onto_mutex!(UPLOAD_LOCK); + let mut purged = false; + for mv_id in database::mv_ids_for_music(music_id) { + let stored = database::get_mv(mv_id); + database::delete_mv(mv_id); + purged = true; + if let Some(stored) = stored { + gc_blobs(&stored["files"]); + } + } + if purged { + database::bump_revision(); + } + drop(lock); +} + +// Startup GC for the content-addressed blob store, mirroring +// custom_song::sweep_audio: the only writers are upload and update, so an +// unreferenced file is a leftover from an interrupted one. Deliberately +// fail-closed - anything that makes the reference set doubtful (unreadable +// catalog, unparseable blob, an entry without a proper md5) aborts the whole +// sweep instead of treating that MV as referencing nothing. Only exactly +// {32 hex}.bin names are ever considered +pub fn sweep_blobs() { + if disabled() { + return; + } + let lock = lock_onto_mutex!(UPLOAD_LOCK); + let Some(blobs) = database::all_mv_blobs() else { + println!("Custom 3DMV blob sweep: catalog unreadable, skipped"); + return; + }; + let mut referenced: Vec = Vec::new(); + for blob in blobs.members() { + let Ok(mv) = jzon::parse(&blob.to_string()) else { + println!("Custom 3DMV blob sweep: unparseable catalog row, skipped"); + return; + }; + if mv["files"].is_empty() { + println!("Custom 3DMV blob sweep: MV {} has no files, skipped", mv["mv_id"]); + return; + } + for file in mv["files"].members() { + let md5 = file["md5"].as_str().unwrap_or(""); + if md5.len() != 32 { + println!("Custom 3DMV blob sweep: MV {} has a malformed file entry, skipped", mv["mv_id"]); + return; + } + referenced.push(String::from(md5)); + } + } + + // No directory means nothing was ever uploaded + let Ok(entries) = fs::read_dir(get_data_path("custom_3dmv/blobs")) else { + return; + }; + let mut removed = 0; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let Some(md5) = name.strip_suffix(".bin") else { continue; }; + if md5.len() != 32 || !md5.chars().all(|c| c.is_ascii_hexdigit()) { + continue; + } + if referenced.iter().any(|other| other == md5) { + continue; + } + if fs::remove_file(entry.path()).is_ok() { + removed += 1; + } + } + if removed > 0 { + println!("Custom 3DMV blob sweep: removed {} orphaned blob(s)", removed); + } + drop(lock); +} + +// The concrete upload bounds, served to the webui so the form can enforce +// them client-side +pub fn upload_limits() -> JsonValue { + object!{ + "max_member_count": MAX_MEMBER_COUNT, + "max_file_bytes": MAX_FILE_BYTES, + "max_request_bytes": MAX_REQUEST_BYTES, + "max_mvs_per_user": MAX_MVS_PER_USER, + "stages": STAGES.to_vec(), + "default_stage": STAGES[0], + "roles": { + "model": { "per_slot": true, "required": true, "kind": "pmx-zip" }, + "motion": { "per_slot": true, "required": true, "kind": "vmd" }, + "facial": { "per_slot": true, "required": false, "kind": "vmd" }, + "camera": { "per_slot": false, "required": false, "kind": "vmd" }, + "config": { "per_slot": false, "required": false, "kind": "json" }, + "stage": { "per_slot": false, "required": false, "kind": "pmx-zip" } + } + } +} + +async fn upload(req: HttpRequest, payload: Multipart) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let mut fields = match read_multipart(payload).await { + Ok(fields) => fields, + Err(e) => return webui::error(&e) + }; + // An export package from another server: its contents map 1:1 onto the + // normal upload fields, so importing is just an upload + if let Some(bytes) = fields.remove("package") { + if !bytes.is_empty() { + if let Err(e) = package::expand(&bytes, &mut fields) { + return webui::error(&e); + } + } + } + match create_mv(uid, &fields) { + Ok(mv_id) => send_json(object!{ + result: "OK", + mv_id: mv_id + }), + Err(e) => webui::error(&e) + } +} + +async fn update(req: HttpRequest, payload: Multipart) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let fields = match read_multipart(payload).await { + Ok(fields) => fields, + Err(e) => return webui::error(&e) + }; + let mv_id = field_str(&fields, "mv_id").parse::().unwrap_or(0); + match update_mv(uid, mv_id, &fields) { + Ok(()) => send_json(object!{ + result: "OK", + mv_id: mv_id + }), + Err(e) => webui::error(&e) + } +} + +async fn publish(req: HttpRequest, body: String) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + let Some(published) = body["published"].as_bool() else { + return webui::error("published must be true or false"); + }; + match set_mv_flags(uid, body["mv_id"].as_i64().unwrap_or(0), published) { + Ok(()) => send_json(object!{ + result: "OK" + }), + Err(e) => webui::error(&e) + } +} + +async fn delete(req: HttpRequest, body: String) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + let body = jzon::parse(&body).unwrap_or(object!{}); + match delete_mv(uid, body["mv_id"].as_i64().unwrap_or(0)) { + Ok(()) => send_json(object!{ + result: "OK" + }), + Err(e) => webui::error(&e) + } +} + +async fn mine(req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let Some(uid) = get_session_uid(&req) else { + return webui::error("Not logged in"); + }; + send_json(object!{ + result: "OK", + mvs: database::get_mvs_by_owner(uid) + }) +} + +// The public MV browser: the published catalog closed over the songs the +// viewer can see, with uploader names. Anonymous viewers get the MVs on +// public songs - published means public +async fn browse(req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let viewer = get_session_uid(&req).unwrap_or(0); + let mut mvs = database::get_browse_mvs(&allowed_music_ids(viewer)); + for mv in mvs.members_mut() { + mv["uploader"] = userdata::get_name_and_rank(mv["owner_id"].as_i64().unwrap_or(0))["user_name"].clone(); + mv.remove("owner_id"); + } + send_json(object!{ + result: "OK", + mvs: mvs + }) +} + +// Download an MV as an export package, re-uploadable on any ew server. The +// viewer must be able to see it: their own, or published +async fn download(req: HttpRequest) -> HttpResponse { + if disabled() { + return HttpResponse::NotFound().finish(); + } + let mv_id = req.match_info().get("mv_id").unwrap_or("").parse::().unwrap_or(0); + let Some(owner) = database::get_mv_owner(mv_id) else { + return webui::error("MV not found"); + }; + if get_session_uid(&req) != Some(owner) && !database::is_published(mv_id) { + return webui::error("MV not found"); + } + match package::build(mv_id) { + Ok(bytes) => { + HttpResponse::Ok() + .insert_header(("content-type", "application/zip")) + .insert_header(("content-disposition", format!("attachment; filename=\"custom_3dmv_{}.zip\"", mv_id))) + .insert_header(("content-length", bytes.len())) + .body(bytes) + }, + Err(e) => webui::error(&e) + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use std::io::Write; + use crate::router::custom_card::tests::with_permissions; + + pub fn field(fields: &mut Fields, key: &str, value: &str) { + fields.insert(String::from(key), value.as_bytes().to_vec()); + } + + // A structurally complete VMD: 2 bone keys, 1 morph, 1 camera, empty + // light/shadow sections and 1 property key with 1 IK toggle. The seed + // lands in the model-name padding and the record tails, so different + // seeds give different md5s + pub fn test_vmd(seed: u8) -> Vec { + let mut rv = Vec::new(); + rv.extend(b"Vocaloid Motion Data 0002"); + rv.resize(30, 0); + rv.extend(b"TestModel"); + rv.resize(50, 0); + rv[49] = seed; + rv.extend(2u32.to_le_bytes()); + for i in 0..2u8 { + let mut record = vec![0u8; 111]; + record[0] = b'b'; + record[1] = b'0' + i; + record[15..19].copy_from_slice(&(i as u32 * 30).to_le_bytes()); + record[110] = seed; + rv.extend(record); + } + rv.extend(1u32.to_le_bytes()); + let mut morph = vec![0u8; 23]; + morph[0] = b'm'; + morph[22] = seed; + rv.extend(morph); + rv.extend(1u32.to_le_bytes()); + let mut camera = vec![0u8; 61]; + camera[60] = seed; + rv.extend(camera); + rv.extend(0u32.to_le_bytes()); + rv.extend(0u32.to_le_bytes()); + rv.extend(1u32.to_le_bytes()); + rv.extend(0u32.to_le_bytes()); + rv.push(1); + rv.extend(1u32.to_le_bytes()); + let mut ik = vec![0u8; 21]; + ik[0] = b'i'; + ik[20] = 1; + rv.extend(ik); + rv + } + + // A camera-only VMD: empty bone/morph sections, 2 camera keys, then EOF - + // the later sections are legitimately absent + pub fn test_camera_vmd(seed: u8) -> Vec { + let mut rv = Vec::new(); + rv.extend(b"Vocaloid Motion Data 0002"); + rv.resize(30, 0); + rv.extend(b"CameraModel"); + rv.resize(50, 0); + rv[49] = seed; + rv.extend(0u32.to_le_bytes()); + rv.extend(0u32.to_le_bytes()); + rv.extend(2u32.to_le_bytes()); + for i in 0..2u8 { + let mut record = vec![0u8; 61]; + record[..4].copy_from_slice(&(i as u32 * 30).to_le_bytes()); + record[60] = seed; + rv.extend(record); + } + rv + } + + fn zip_with(name: &str, bytes: &[u8]) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zip.write_all(bytes).unwrap(); + zip.finish().unwrap().into_inner() + } + + // A minimal model zip: one PMX entry (magic + version + a seeded tail) + // plus a texture entry the validator must skip over + pub fn test_pmx_zip(seed: u8, version: f32) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + zip.start_file("model.pmx", options).unwrap(); + let mut pmx = Vec::new(); + pmx.extend(b"PMX "); + pmx.extend(version.to_le_bytes()); + pmx.extend([8u8, seed, 0, 0]); + zip.write_all(&pmx).unwrap(); + zip.start_file("tex/body.png", options).unwrap(); + zip.write_all(&[seed, 1, 2, 3]).unwrap(); + zip.finish().unwrap().into_inner() + } + + // A stage PMX zip with the full header walk to the vertex count: globals, + // four empty text fields, then `vertices`. The seed lands in the globals + // so different seeds give different md5s + pub fn test_stage_zip(seed: u8, vertices: i32) -> Vec { + let mut pmx = Vec::new(); + pmx.extend(b"PMX "); + pmx.extend(2.0f32.to_le_bytes()); + pmx.push(8); + pmx.extend([0, 0, 0, 0, 0, 0, 0, seed]); + for _ in 0..4 { + pmx.extend(0u32.to_le_bytes()); + } + pmx.extend(vertices.to_le_bytes()); + zip_with("stage.pmx", &pmx) + } + + // A catalog row is all an MV needs from a song; the full upload pipeline + // is custom_song's own test surface + pub fn seed_song(music_id: i64, owner: i64, visibility: &str) { + crate::database::custom_song::insert_song(music_id, owner, &object!{ + "music_id": music_id, + "name": format!("Seed Song {}", music_id), + "sound": { "play": { "md5": "0".repeat(32) }, "select": { "md5": "0".repeat(32) } } + }, visibility, &array![], false); + } + + // A complete, valid 2-slot upload: model+motion per slot, a facial on + // slot 1, a camera and a config. Seeds must be >= 5 apart between tests + // (slot files use seed+slot, the facial seed+3) + pub fn base_fields(music_id: i64, member_count: i64, seed: u8) -> Fields { + let mut fields = Fields::new(); + field(&mut fields, "name", "Test MV"); + field(&mut fields, "name_en", "Test MV EN"); + field(&mut fields, "music_id", &music_id.to_string()); + field(&mut fields, "member_count", &member_count.to_string()); + for slot in 1..=member_count { + fields.insert(format!("model_{}", slot), test_pmx_zip(seed + slot as u8, 2.0)); + fields.insert(format!("motion_{}", slot), test_vmd(seed + slot as u8)); + } + fields.insert(String::from("facial_1"), test_vmd(seed + 3)); + fields.insert(String::from("camera"), test_camera_vmd(seed)); + fields.insert(String::from("config"), br#"{"scale":1.0,"world_offset":[0,0,0]}"#.to_vec()); + fields + } + + pub fn wipe(uid: i64) { + for mv in database::get_mvs_by_owner(uid).members() { + let _ = delete_mv(uid, mv["mv_id"].as_i64().unwrap()); + } + } + + fn file_md5(mv: &JsonValue, role: &str, slot: Option) -> String { + mv["files"].members() + .find(|f| f["role"] == role && slot.map_or(f["slot"].is_null(), |slot| f["slot"] == slot)) + .map(|f| f["md5"].to_string()).unwrap_or_default() + } + + // The whole feature is off unless --enable-custom-3dmv: endpoints 404 and + // the cross-module helpers (the song-delete cascade) never touch the table + #[test] + fn feature_gate_hides_everything_when_disabled() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(9_100_020); + seed_song(970099, 9_100_020, "public"); + let id = create_mv(9_100_020, &base_fields(970099, 1, 90)).unwrap(); + + crate::runtime::set_enable_custom_3dmv(false); + assert!(disabled()); + let resp = actix_web::rt::System::new().block_on(async { + data(actix_web::test::TestRequest::default().to_http_request()).await + }); + assert_eq!(resp.status(), actix_web::http::StatusCode::NOT_FOUND); + // The cascade is a no-op while disabled - nothing may touch the db + purge_song(970099); + crate::runtime::set_enable_custom_3dmv(true); + assert!(database::get_mv(id).is_some(), "disabled purge must not touch the table"); + + // Enabled again, the same cascade works + purge_song(970099); + assert!(database::get_mv(id).is_none()); + wipe(9_100_020); + } + + // A full create: the catalog entry the client parses, the blob store, the + // md5 index, draft visibility and the publish flip + #[test] + fn upload_happy_path_builds_the_catalog_entry() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(9_100_001); + wipe(9_100_002); + seed_song(970001, 9_100_001, "public"); + + let fields = base_fields(970001, 2, 10); + let id = create_mv(9_100_001, &fields).unwrap(); + assert!(id >= database::FIRST_MV_ID); + + let mv = database::get_mv(id).unwrap(); + assert_eq!(mv["mv_id"].as_i64(), Some(id)); + assert_eq!(mv["music_id"].as_i64(), Some(970001)); + assert_eq!(mv["name"].as_str(), Some("Test MV")); + assert_eq!(mv["name_en"].as_str(), Some("Test MV EN")); + assert_eq!(mv["member_count"].as_i64(), Some(2)); + // model+motion per slot, facial on slot 1, camera, config + assert_eq!(mv["files"].len(), 7); + for slot in 1..=2 { + for role in ["model", "motion"] { + assert!(mv["files"].members().any(|f| f["role"] == role && f["slot"] == slot), "{} {}", role, slot); + } + } + assert!(mv["files"].members().any(|f| f["role"] == "facial" && f["slot"] == 1)); + assert!(mv["files"].members().any(|f| f["role"] == "camera" && f["slot"].is_null())); + assert!(mv["files"].members().any(|f| f["role"] == "config" && f["slot"].is_null())); + + // Every entry hashes the exact bytes in the blob store and the data + // route's index resolves it + for file in mv["files"].members() { + let md5 = file["md5"].to_string(); + assert_eq!(md5.len(), 32); + let bytes = fs::read(blob_path(&md5)).unwrap(); + assert_eq!(format!("{:x}", md5::compute(&bytes)), md5); + assert_eq!(bytes.len(), file["size"].as_usize().unwrap()); + assert!(database::find_blob_by_md5(&md5)); + } + assert!(!database::find_blob_by_md5(&"f".repeat(32))); + + // A draft: owner-only + assert!(catalog_for_user(9_100_001).members().any(|m| m["mv_id"] == id)); + assert!(!catalog_for_user(9_100_002).members().any(|m| m["mv_id"] == id)); + + // The owner publishes without any scope; once published (and the song + // is public) everyone resolves it + set_mv_flags(9_100_001, id, true).unwrap(); + assert!(database::is_published(id)); + assert!(catalog_for_user(9_100_002).members().any(|m| m["mv_id"] == id)); + + // The export package round-trips through expand into the same fields + let package = package::build(id).unwrap(); + let mut expanded = Fields::new(); + package::expand(&package, &mut expanded).unwrap(); + assert_eq!(field_str(&expanded, "name"), "Test MV"); + assert_eq!(field_str(&expanded, "music_id"), "970001"); + assert_eq!(field_str(&expanded, "member_count"), "2"); + for key in ["model_1", "motion_1", "facial_1", "model_2", "motion_2", "camera", "config"] { + assert_eq!(expanded.get(key), fields.get(key), "package entry {}", key); + } + // A form-supplied music_id survives the expand (server-local id) + let mut refit = Fields::new(); + field(&mut refit, "music_id", "970099"); + package::expand(&package, &mut refit).unwrap(); + assert_eq!(field_str(&refit, "music_id"), "970099"); + + wipe(9_100_001); + wipe(9_100_002); + } + + #[test] + fn every_validation_rejection() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(9_100_003); + seed_song(970003, 9_100_003, "public"); + seed_song(970004, 9_100_013, "private"); + + let run = |fields: &Fields| create_mv(9_100_003, fields); + let base = || base_fields(970003, 2, 30); + let mutated = |key: &str, value: &str| { + let mut fields = base(); + field(&mut fields, key, value); + fields + }; + + assert!(run(&mutated("name", "")).unwrap_err().contains("MV name is required")); + assert!(run(&mutated("name", "x")).unwrap_err().contains("")); + assert!(run(&mutated("name_en", "")).unwrap_err().contains("")); + // The song must exist and be the uploader's or public + assert!(run(&mutated("music_id", "999")).unwrap_err().contains("Unknown music_id")); + assert!(run(&mutated("music_id", "970004")).unwrap_err().contains("Unknown music_id")); + assert!(run(&mutated("member_count", "0")).unwrap_err().contains("member_count must be 1-12")); + assert!(run(&mutated("member_count", "13")).unwrap_err().contains("member_count must be 1-12")); + + // Every slot needs a model and a motion + let mut fields = base(); + fields.remove("model_2"); + assert!(run(&fields).unwrap_err().contains("'model_2' is required")); + let mut fields = base(); + fields.remove("motion_2"); + assert!(run(&fields).unwrap_err().contains("'motion_2' is required")); + + // VMD structure: garbage, wrong magic, truncation + let mut fields = base(); + fields.insert(String::from("motion_1"), b"not a vmd".to_vec()); + assert!(run(&fields).unwrap_err().contains("VMD")); + let mut fields = base(); + fields.insert(String::from("motion_1"), test_vmd(31)[..60].to_vec()); + assert!(run(&fields).unwrap_err().contains("truncated")); + let mut fields = base(); + let mut v1 = b"Vocaloid Motion Data file".to_vec(); + v1.resize(60, 0); + fields.insert(String::from("camera"), v1); + assert!(run(&fields).unwrap_err().contains("version 1")); + + // Model zip: not a zip, no pmx entry, bad magic, unsupported version + let mut fields = base(); + fields.insert(String::from("model_1"), b"definitely not a zip".to_vec()); + assert!(run(&fields).unwrap_err().contains("not a valid zip")); + let mut fields = base(); + fields.insert(String::from("model_1"), zip_with("readme.txt", b"no model here")); + assert!(run(&fields).unwrap_err().contains("no .pmx model entry")); + let mut fields = base(); + fields.insert(String::from("model_1"), zip_with("model.pmx", b"XMP 1234abcd")); + assert!(run(&fields).unwrap_err().contains("missing the \"PMX \" magic")); + let mut fields = base(); + fields.insert(String::from("model_1"), test_pmx_zip(32, 1.0)); + assert!(run(&fields).unwrap_err().contains("only 2.0 and 2.1")); + // 2.1 is fine (deleted right away to keep the owner count honest) + let mut fields = base(); + fields.insert(String::from("model_1"), test_pmx_zip(33, 2.1)); + let ok_id = run(&fields).unwrap(); + delete_mv(9_100_003, ok_id).unwrap(); + + // Config must parse as JSON (schema is the client's business, except + // "stage", which must be one of the in-game stage scene names, and + // "stage_scale", a number in the client's clamp range) + let mut fields = base(); + fields.insert(String::from("config"), b"{not json".to_vec()); + assert!(run(&fields).unwrap_err().contains("not valid JSON")); + let mut fields = base(); + fields.insert(String::from("config"), br#"{"stage":"bg9999_01_s1"}"#.to_vec()); + assert!(run(&fields).unwrap_err().contains("unknown stage")); + let mut fields = base(); + fields.insert(String::from("config"), br#"{"stage":7}"#.to_vec()); + assert!(run(&fields).unwrap_err().contains("unknown stage")); + for bad_scale in [r#""big""#, "0.004", "1.5"] { + let mut fields = base(); + fields.insert(String::from("config"), format!(r#"{{"stage_scale":{}}}"#, bad_scale).into_bytes()); + assert!(run(&fields).unwrap_err().contains("stage_scale must be a number between"), "stage_scale {}", bad_scale); + } + + // A custom stage must be a PMX zip like a model, plus actual geometry + let mut fields = base(); + fields.insert(String::from("stage"), b"definitely not a zip".to_vec()); + assert!(run(&fields).unwrap_err().contains("not a valid zip")); + let mut fields = base(); + fields.insert(String::from("stage"), zip_with("props.txt", b"no model here")); + assert!(run(&fields).unwrap_err().contains("no .pmx model entry")); + let mut fields = base(); + fields.insert(String::from("stage"), test_stage_zip(36, 0)); + assert!(run(&fields).unwrap_err().contains("no vertices")); + // A header that ends before the vertex count (the model-role fixture + // stops right after the version) is malformed as a stage + let mut fields = base(); + fields.insert(String::from("stage"), test_pmx_zip(37, 2.0)); + assert!(run(&fields).unwrap_err().contains("malformed PMX header")); + // A recognized stage passes (deleted right away like the 2.1 model) + let mut fields = base(); + fields.insert(String::from("config"), br#"{"stage":"bg0008_01_s1","scale":1.0}"#.to_vec()); + let ok_id = run(&fields).unwrap(); + delete_mv(9_100_003, ok_id).unwrap(); + + // Only the deliberate successes above ever wrote a row + assert_eq!(database::mv_count_for_owner(9_100_003), 0); + wipe(9_100_003); + } + + // Managing MVs is permissionless beyond login (like custom songs): any + // user creates/publishes their own, nobody without 3dmv.edit touches + // someone else's + #[test] + fn ownership_gates_and_moderation() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(9_100_004); + wipe(9_100_005); + seed_song(970005, 9_100_004, "public"); + + // Publishing at create needs nothing but the login either + let mut published_fields = base_fields(970005, 1, 45); + field(&mut published_fields, "published", "1"); + let published_id = create_mv(9_100_004, &published_fields).unwrap(); + assert!(database::is_published(published_id)); + + let id = create_mv(9_100_004, &base_fields(970005, 1, 40)).unwrap(); + + // A stranger (no scopes) can't touch someone else's + let mut edit = Fields::new(); + field(&mut edit, "name", "Hijacked"); + assert!(update_mv(9_100_005, id, &edit).unwrap_err().contains("only manage your own")); + assert!(delete_mv(9_100_005, id).unwrap_err().contains("only manage your own")); + assert!(set_mv_flags(9_100_005, id, true).unwrap_err().contains("only manage your own")); + + // 3dmv.edit is moderation: manage ANY MV + with_permissions(9_100_005, &[permissions::MV_EDIT], || { + update_mv(9_100_005, id, &edit).unwrap(); + set_mv_flags(9_100_005, id, true).unwrap(); + set_mv_flags(9_100_005, id, false).unwrap(); + }); + assert_eq!(database::get_mv(id).unwrap()["name"].to_string(), "Hijacked"); + with_permissions(9_100_005, &[permissions::MV_EDIT], || delete_mv(9_100_005, id).unwrap()); + assert!(database::get_mv(id).is_none()); + + wipe(9_100_004); + wipe(9_100_005); + } + + // With the upload permission gone, the login session is the only gate on + // the management endpoints: a sessionless request is rejected before any + // form field is even parsed + #[test] + fn not_logged_in_is_rejected() { + let _lock = crate::runtime::lock_test_data_path(); + actix_web::rt::System::new().block_on(async { + let body_of = |resp: HttpResponse| async { + let bytes = actix_web::body::to_bytes(resp.into_body()).await.unwrap(); + String::from_utf8_lossy(&bytes).to_string() + }; + let (req, mut payload) = actix_web::test::TestRequest::default().to_http_parts(); + let multipart = ::from_request(&req, &mut payload).await.unwrap(); + assert!(body_of(upload(actix_web::test::TestRequest::default().to_http_request(), multipart).await).await.contains("Not logged in")); + let (req, mut payload) = actix_web::test::TestRequest::default().to_http_parts(); + let multipart = ::from_request(&req, &mut payload).await.unwrap(); + assert!(body_of(update(actix_web::test::TestRequest::default().to_http_request(), multipart).await).await.contains("Not logged in")); + let publish_body = jzon::stringify(object!{ mv_id: 1, published: true }); + assert!(body_of(publish(actix_web::test::TestRequest::default().to_http_request(), publish_body).await).await.contains("Not logged in")); + let delete_body = jzon::stringify(object!{ mv_id: 1 }); + assert!(body_of(delete(actix_web::test::TestRequest::default().to_http_request(), delete_body).await).await.contains("Not logged in")); + assert!(body_of(mine(actix_web::test::TestRequest::default().to_http_request()).await).await.contains("Not logged in")); + }); + } + + // Present files replace (old blobs GC), absent files keep, `_delete` + // drops optional roles, and the slot-completeness rule holds for the + // resulting member_count + #[test] + fn update_keeps_and_deletes_files() { + let _lock = crate::runtime::lock_test_data_path(); + wipe(9_100_006); + seed_song(970006, 9_100_006, "public"); + let upload = |fields: &Fields| create_mv(9_100_006, fields); + let edit = |id: i64, fields: &Fields| update_mv(9_100_006, id, fields); + + let id = upload(&base_fields(970006, 2, 50)).unwrap(); + let before = database::get_mv(id).unwrap(); + let old_motion = file_md5(&before, "motion", Some(1)); + let old_model = file_md5(&before, "model", Some(1)); + + // Replace one motion, rename; everything else keeps + let mut fields = Fields::new(); + field(&mut fields, "name", "Renamed"); + fields.insert(String::from("motion_1"), test_vmd(60)); + edit(id, &fields).unwrap(); + let after = database::get_mv(id).unwrap(); + assert_eq!(after["name"].as_str(), Some("Renamed")); + assert_eq!(after["name_en"], before["name_en"]); + assert_eq!(after["member_count"].as_i64(), Some(2)); + assert_eq!(after["music_id"], before["music_id"]); + let new_motion = file_md5(&after, "motion", Some(1)); + assert_ne!(new_motion, old_motion); + assert_eq!(file_md5(&after, "model", Some(1)), old_model); + // The replaced blob is gone (nothing else references it), the new and + // the kept ones exist + assert!(fs::read(blob_path(&old_motion)).is_err()); + assert!(!database::find_blob_by_md5(&old_motion)); + assert!(fs::read(blob_path(&new_motion)).is_ok()); + assert!(fs::read(blob_path(&old_model)).is_ok()); + + // Optional roles delete by flag; blobs follow + let facial = file_md5(&after, "facial", Some(1)); + let camera = file_md5(&after, "camera", None); + let mut fields = Fields::new(); + field(&mut fields, "facial_1_delete", "1"); + field(&mut fields, "camera_delete", "1"); + edit(id, &fields).unwrap(); + let after = database::get_mv(id).unwrap(); + assert!(!after["files"].members().any(|f| f["role"] == "facial")); + assert!(!after["files"].members().any(|f| f["role"] == "camera")); + assert!(fs::read(blob_path(&facial)).is_err()); + assert!(fs::read(blob_path(&camera)).is_err()); + + // Replace + delete on the same file is contradictory; required roles + // can't be deleted at all + let mut fields = Fields::new(); + field(&mut fields, "camera_delete", "1"); + fields.insert(String::from("camera"), test_camera_vmd(55)); + assert!(edit(id, &fields).unwrap_err().contains("cannot both replace and delete")); + let mut fields = Fields::new(); + field(&mut fields, "model_1_delete", "1"); + assert!(edit(id, &fields).unwrap_err().contains("cannot be deleted")); + + // Shrinking member_count drops the higher slots and their blobs + let slot2_model = file_md5(&database::get_mv(id).unwrap(), "model", Some(2)); + let mut fields = Fields::new(); + field(&mut fields, "member_count", "1"); + edit(id, &fields).unwrap(); + let after = database::get_mv(id).unwrap(); + assert!(!after["files"].members().any(|f| f["slot"] == 2)); + assert!(fs::read(blob_path(&slot2_model)).is_err()); + + // Growing it back demands the new slots' files + let mut fields = Fields::new(); + field(&mut fields, "member_count", "2"); + assert!(edit(id, &fields).unwrap_err().contains("'model_2' is required")); + // A rejected edit wrote nothing + assert_eq!(database::get_mv(id).unwrap()["member_count"].as_i64(), Some(1)); + + wipe(9_100_006); + } + + // The optional slot-less "stage" role: a custom PMX stage carried exactly + // like camera/config, with the same keep/replace/delete semantics, plus + // the stage_scale boundary values and the package round-trip + #[test] + fn stage_role_upload_update_and_package() { + let _lock = crate::runtime::lock_test_data_path(); + let uid = 9_100_012; + wipe(uid); + seed_song(970041, uid, "public"); + + let mut fields = base_fields(970041, 1, 110); + fields.insert(String::from("stage"), test_stage_zip(111, 42)); + fields.insert(String::from("config"), br#"{"stage":"bg0008_01_s1","stage_scale":0.08}"#.to_vec()); + let id = create_mv(uid, &fields).unwrap(); + + // The catalog carries a slot-less stage entry addressing the exact + // bytes the data route serves + let mv = database::get_mv(id).unwrap(); + let entry = mv["files"].members().find(|f| f["role"] == "stage").unwrap(); + assert!(entry["slot"].is_null()); + let md5 = entry["md5"].to_string(); + let bytes = fs::read(blob_path(&md5)).unwrap(); + assert_eq!(format!("{:x}", md5::compute(&bytes)), md5); + assert_eq!(bytes.len(), entry["size"].as_usize().unwrap()); + assert!(database::find_blob_by_md5(&md5)); + + // The clamp-range boundaries are valid stage_scale values + for scale in ["0.005", "1"] { + let mut edit = Fields::new(); + edit.insert(String::from("config"), format!(r#"{{"stage_scale":{}}}"#, scale).into_bytes()); + update_mv(uid, id, &edit).unwrap(); + } + + // An absent field keeps the stored stage; a new file replaces it and + // the old blob GCs + let mut edit = Fields::new(); + field(&mut edit, "name", "Renamed"); + update_mv(uid, id, &edit).unwrap(); + assert_eq!(file_md5(&database::get_mv(id).unwrap(), "stage", None), md5); + let mut edit = Fields::new(); + edit.insert(String::from("stage"), test_stage_zip(112, 7)); + update_mv(uid, id, &edit).unwrap(); + let new_md5 = file_md5(&database::get_mv(id).unwrap(), "stage", None); + assert_ne!(new_md5, md5); + assert!(fs::read(blob_path(&md5)).is_err()); + assert!(fs::read(blob_path(&new_md5)).is_ok()); + + // The export package carries the stage blob and expands it back onto + // the same field name + let package = package::build(id).unwrap(); + let mut expanded = Fields::new(); + package::expand(&package, &mut expanded).unwrap(); + assert_eq!(expanded.get("stage"), Some(&test_stage_zip(112, 7))); + + // Replace + delete is contradictory; a plain stage_delete drops the + // role and its blob + let mut edit = Fields::new(); + field(&mut edit, "stage_delete", "1"); + edit.insert(String::from("stage"), test_stage_zip(113, 5)); + assert!(update_mv(uid, id, &edit).unwrap_err().contains("cannot both replace and delete")); + let mut edit = Fields::new(); + field(&mut edit, "stage_delete", "1"); + update_mv(uid, id, &edit).unwrap(); + assert!(!database::get_mv(id).unwrap()["files"].members().any(|f| f["role"] == "stage")); + assert!(fs::read(blob_path(&new_md5)).is_err()); + + wipe(uid); + } + + // The referential closure: a published MV is only served to users whose + // OWN song catalog delivers its music_id + #[test] + fn catalog_closure_follows_song_visibility() { + let _lock = crate::runtime::lock_test_data_path(); + let owner = 9_100_007; + let stranger = 9_100_008; + wipe(owner); + seed_song(970011, owner, "public"); + seed_song(970012, owner, "private"); + + let mut fields = base_fields(970011, 1, 100); + field(&mut fields, "published", "1"); + let public_song_mv = create_mv(owner, &fields).unwrap(); + let mut fields = base_fields(970012, 1, 105); + field(&mut fields, "published", "1"); + let private_song_mv = create_mv(owner, &fields).unwrap(); + + // The owner's song catalog carries both songs, so both MVs resolve + let owner_catalog = catalog_for_user(owner); + assert!(owner_catalog.members().any(|m| m["mv_id"] == public_song_mv)); + assert!(owner_catalog.members().any(|m| m["mv_id"] == private_song_mv)); + // A stranger's catalog delivers only the public song - the published + // MV on the private song must NOT be served + let stranger_catalog = catalog_for_user(stranger); + assert!(stranger_catalog.members().any(|m| m["mv_id"] == public_song_mv)); + assert!(!stranger_catalog.members().any(|m| m["mv_id"] == private_song_mv)); + + // Sharing the song brings its MV along + crate::database::custom_song::set_visibility(970012, "shared", &array![stranger]); + assert!(catalog_for_user(stranger).members().any(|m| m["mv_id"] == private_song_mv)); + crate::database::custom_song::set_visibility(970012, "private", &array![]); + + wipe(owner); + } + + // Deletion GC keeps shared blobs alive, dead_mv_ids reports only deleted + // band ids, and the song-delete cascade purges the song's MVs + #[test] + fn delete_gc_cascade_and_dead_ids() { + let _lock = crate::runtime::lock_test_data_path(); + let uid = 9_100_009; + wipe(uid); + seed_song(970021, uid, "public"); + seed_song(970022, uid, "public"); + + // Two MVs sharing one motion blob (content-addressed store) + let shared_vmd = test_vmd(70); + let shared_md5 = format!("{:x}", md5::compute(&shared_vmd)); + let mut f1 = base_fields(970021, 1, 71); + f1.insert(String::from("motion_1"), shared_vmd.clone()); + let mut f2 = base_fields(970022, 1, 76); + f2.insert(String::from("motion_1"), shared_vmd.clone()); + let id1 = create_mv(uid, &f1).unwrap(); + let id2 = create_mv(uid, &f2).unwrap(); + let model1 = file_md5(&database::get_mv(id1).unwrap(), "model", Some(1)); + + delete_mv(uid, id1).unwrap(); + assert!(database::get_mv(id1).is_none()); + // The shared blob survives (id2 still references it), the unique one + // is gone + assert!(fs::read(blob_path(&shared_md5)).is_ok()); + assert!(fs::read(blob_path(&model1)).is_err()); + + // Deleted band ids come back; alive and out-of-band ids never do + let dead = database::dead_mv_ids(&array![id1, id2, 15000, 100000, id1]); + assert_eq!(dead.len(), 1); + assert_eq!(dead[0].as_i64(), Some(id1)); + + // The cascade: purging the song deletes its MV, GCs its blobs and + // bumps the revision once + let revision = database::get_revision(); + purge_song(970022); + assert!(database::get_mv(id2).is_none()); + assert!(fs::read(blob_path(&shared_md5)).is_err()); + assert_eq!(database::get_revision(), revision + 1); + assert!(database::dead_mv_ids(&array![id2]).contains(id2)); + // A song with no MVs purges to a no-op, revision untouched + purge_song(970021); + assert_eq!(database::get_revision(), revision + 1); + + wipe(uid); + } + + // The startup sweep removes exactly the orphans: referenced blobs and + // non-{md5}.bin names stay + #[test] + fn sweep_removes_only_orphan_blobs() { + let _lock = crate::runtime::lock_test_data_path(); + let uid = 9_100_010; + wipe(uid); + seed_song(970031, uid, "public"); + let id = create_mv(uid, &base_fields(970031, 1, 80)).unwrap(); + let mv = database::get_mv(id).unwrap(); + + let orphan = blob_path(&"a".repeat(32)); + fs::write(&orphan, b"orphaned by an interrupted upload").unwrap(); + let junk = get_data_path("custom_3dmv/blobs/notahash.bin"); + fs::write(&junk, b"not ours to manage").unwrap(); + + sweep_blobs(); + + assert!(fs::read(&orphan).is_err()); + assert!(fs::read(&junk).is_ok()); + for file in mv["files"].members() { + assert!(fs::read(blob_path(&file["md5"].to_string())).is_ok(), "referenced blob {} must survive", file["md5"]); + } + let _ = fs::remove_file(&junk); + wipe(uid); + } +} diff --git a/src/router/custom_3dmv/package.rs b/src/router/custom_3dmv/package.rs new file mode 100644 index 0000000..96a5a1e --- /dev/null +++ b/src/router/custom_3dmv/package.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; +use std::fs; +use std::io::{Cursor, Read, Seek, Write}; +use zip::write::SimpleFileOptions; + +use super::{blob_path, field_key}; +use crate::database::custom_3dmv as database; + +// Export packages carry the stored blobs byte-for-byte (they ARE the original +// uploads - nothing is transcoded) plus the upload metadata, so an MV can be +// re-uploaded on any ew server. Layout of the zip: +// manifest.json {format, name, name_en, music_id, member_count} +// model_{slot} / motion_{slot} / facial_{slot} / camera / config / stage +// published is a per-server setting and deliberately not part of the package. + +pub fn build(mv_id: i64) -> Result, String> { + let mv = database::get_mv(mv_id).ok_or(String::from("MV not found"))?; + + let manifest = jzon::object!{ + "format": 1, + "name": mv["name"].clone(), + "name_en": mv["name_en"].clone(), + "music_id": mv["music_id"].clone(), + "member_count": mv["member_count"].clone() + }; + + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = SimpleFileOptions::default(); + let mut add = |name: &str, bytes: &[u8]| -> Result<(), String> { + zip.start_file(name, options).map_err(|e| e.to_string())?; + zip.write_all(bytes).map_err(|e| e.to_string()) + }; + + add("manifest.json", jzon::stringify(manifest).as_bytes())?; + for file in mv["files"].members() { + let Some(name) = field_key(file) else { continue; }; + let md5 = file["md5"].as_str().unwrap_or(""); + let bytes = fs::read(blob_path(md5)).map_err(|e| e.to_string())?; + add(&name, &bytes)?; + } + + Ok(zip.finish().map_err(|e| e.to_string())?.into_inner()) +} + +fn read_entry(archive: &mut zip::ZipArchive, name: &str) -> Option> { + let mut file = archive.by_name(name).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + Some(bytes) +} + +// Expands a package into the same field map the upload form produces. The +// package's metadata wins over form fields - except music_id, which is a +// server-local id: a form-supplied song wins, and the manifest's only fills +// in when the form left it blank (same-server re-upload) +pub fn expand(package: &[u8], fields: &mut HashMap>) -> Result<(), String> { + let mut archive = zip::ZipArchive::new(Cursor::new(package)).map_err(|_| String::from("Package is not a valid zip file"))?; + + let manifest = read_entry(&mut archive, "manifest.json").ok_or(String::from("Package has no manifest.json"))?; + let manifest = jzon::parse(&String::from_utf8_lossy(&manifest)).map_err(|_| String::from("Package manifest is not valid JSON"))?; + if manifest["format"].as_i64() != Some(1) { + return Err(String::from("Unsupported package format")); + } + + for key in ["name", "name_en", "member_count"] { + if !manifest[key].is_null() { + fields.insert(key.to_string(), manifest[key].to_string().into_bytes()); + } + } + if !fields.get("music_id").is_some_and(|v| !v.is_empty()) && !manifest["music_id"].is_null() { + fields.insert(String::from("music_id"), manifest["music_id"].to_string().into_bytes()); + } + + let member_count = manifest["member_count"].as_i64().unwrap_or(0); + for slot in 1..=member_count.clamp(0, super::MAX_MEMBER_COUNT) { + for role in ["model", "motion", "facial"] { + if let Some(bytes) = read_entry(&mut archive, &format!("{}_{}", role, slot)) { + fields.insert(format!("{}_{}", role, slot), bytes); + } + } + } + for name in ["camera", "config", "stage"] { + if let Some(bytes) = read_entry(&mut archive, name) { + fields.insert(String::from(name), bytes); + } + } + Ok(()) +} diff --git a/src/router/custom_3dmv/vmd.rs b/src/router/custom_3dmv/vmd.rs new file mode 100644 index 0000000..4db9605 --- /dev/null +++ b/src/router/custom_3dmv/vmd.rs @@ -0,0 +1,121 @@ +// Structural validation of a VMD (Vocaloid Motion Data) upload: the header +// magic plus a full section walk with bounds checks, so a truncated or +// corrupt file is rejected cheaply at upload time instead of crashing a +// client mid-live. Nothing here decodes the animation - the stored bytes are +// served verbatim and the client owns the semantics. +// +// Layout (little-endian): 30-byte magic "Vocaloid Motion Data 0002" +// (NUL-padded), 20-byte model name, then up to 6 sections, each a uint32 +// count followed by fixed-size records - bone (111), morph (23), camera (61), +// light (28), self-shadow (9) - and the property/IK section whose records are +// variable-sized (9 bytes + 21 per IK entry). Camera-only and motion-only +// files legitimately end early: EOF on a section boundary reads as count 0. + +const MAGIC_V2: &[u8] = b"Vocaloid Motion Data 0002"; +const MAGIC_V1: &[u8] = b"Vocaloid Motion Data file"; +const HEADER_LEN: usize = 30 + 20; + +// (record size, section name) for the fixed-size sections, in file order +const FIXED_SECTIONS: &[(usize, &str)] = &[ + (111, "bone"), + (23, "morph"), + (61, "camera"), + (28, "light"), + (9, "self-shadow") +]; + +fn read_count(bytes: &[u8], offset: usize) -> Option { + let end = offset.checked_add(4)?; + Some(u32::from_le_bytes(bytes.get(offset..end)?.try_into().unwrap())) +} + +pub fn validate(label: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.len() < HEADER_LEN { + return Err(format!("'{}' is too short to be a VMD file", label)); + } + if bytes.starts_with(MAGIC_V1) { + return Err(format!("'{}' is a version 1 VMD (\"Vocaloid Motion Data file\") - re-save it as version 2 in MMD", label)); + } + if !bytes.starts_with(MAGIC_V2) { + return Err(format!("'{}' is not a VMD file (missing the \"Vocaloid Motion Data 0002\" header)", label)); + } + + let mut offset = HEADER_LEN; + for (record_size, section) in FIXED_SECTIONS { + // EOF exactly on a section boundary: the remaining sections are absent + if offset == bytes.len() { + return Ok(()); + } + let Some(count) = read_count(bytes, offset) else { + return Err(format!("'{}' is truncated in the {} section header", label, section)); + }; + offset += 4; + let section_len = (count as usize).checked_mul(*record_size) + .filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len())) + .ok_or(format!("'{}' is truncated: the {} section claims {} records past the end of the file", label, section, count))?; + offset += section_len; + } + + // Property/IK section: uint32 frame, byte visible, uint32 ikCount, then + // ikCount x (20-byte bone name + 1-byte enabled) + if offset == bytes.len() { + return Ok(()); + } + let Some(count) = read_count(bytes, offset) else { + return Err(format!("'{}' is truncated in the property section header", label)); + }; + offset += 4; + for _ in 0..count { + let Some(ik_count) = read_count(bytes, offset + 5) else { + return Err(format!("'{}' is truncated in the property section", label)); + }; + let record_len = (ik_count as usize).checked_mul(21) + .and_then(|len| len.checked_add(9)) + .filter(|len| offset.checked_add(*len).is_some_and(|end| end <= bytes.len())) + .ok_or(format!("'{}' is truncated in the property section", label))?; + offset += record_len; + } + // Trailing bytes after the last section are tolerated, like MMD does + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structure_walk_accepts_real_shapes_and_rejects_corruption() { + let vmd = crate::router::custom_3dmv::tests::test_vmd(1); + assert!(validate("motion_1", &vmd).is_ok()); + + // Camera-only file: sections end after camera + let cam = crate::router::custom_3dmv::tests::test_camera_vmd(2); + assert!(validate("camera", &cam).is_ok()); + + // A bare header with every section absent is structurally fine + let mut bare = Vec::new(); + bare.extend(MAGIC_V2); + bare.resize(HEADER_LEN, 0); + assert!(validate("motion_1", &bare).is_ok()); + + // Truncations and lies + assert!(validate("motion_1", &vmd[..vmd.len() - 1]).unwrap_err().contains("truncated")); + assert!(validate("motion_1", &vmd[..HEADER_LEN + 2]).unwrap_err().contains("truncated")); + let mut liar = vmd.clone(); + liar[HEADER_LEN] = 200; // bone count far past EOF + assert!(validate("motion_1", &liar).unwrap_err().contains("claims 200 records")); + // A count that would overflow the length math + let mut overflow = bare.clone(); + overflow.extend(u32::MAX.to_le_bytes()); + assert!(validate("motion_1", &overflow).unwrap_err().contains("truncated")); + + // Wrong or old magic + assert!(validate("motion_1", b"garbage").unwrap_err().contains("too short")); + let mut wrong = vmd.clone(); + wrong[0] = b'X'; + assert!(validate("motion_1", &wrong).unwrap_err().contains("not a VMD")); + let mut v1 = vmd.clone(); + v1[..MAGIC_V1.len()].copy_from_slice(MAGIC_V1); + assert!(validate("motion_1", &v1).unwrap_err().contains("version 1")); + } +} diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index 82a92e9..76bdf41 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -152,6 +152,18 @@ pub fn hidden_live_ids_for_user(uid: i64) -> JsonValue { database::non_public_music_ids_for(uid) } +// Whether `uid` may attach cross-feature content (a custom 3D MV) to this +// song: it must exist, and be theirs or publicly visible. Mirrors +// custom_card::validate_character_ref +pub fn can_reference_song(uid: i64, music_id: i64) -> Result<(), String> { + if !disabled() + && database::get_song_owner(music_id).is_some() + && (database::get_song_owner(music_id) == Some(uid) || database::song_publicly_visible(music_id)) { + return Ok(()); + } + Err(format!("Unknown music_id '{}'", music_id)) +} + fn song_path(music_id: i64, file: &str) -> String { get_data_path(&format!("custom_songs/{}/{}", music_id, file)) } @@ -1135,6 +1147,8 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse { // Global clear-rate stats for the dead live id (per-user score records are // wiped lazily on each user's next userdata pull) crate::router::clear_rate::purge_live(music_id); + // A custom 3D MV can't outlive the song it plays over + crate::router::custom_3dmv::purge_song(music_id); let _ = fs::remove_dir_all(get_data_path(&format!("custom_songs/{}", music_id))); // Audio is content-addressed and may be shared with another upload diff --git a/src/router/webui.rs b/src/router/webui.rs index 31c9bfc..6aa163b 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -229,6 +229,7 @@ pub fn server_info(_req: HttpRequest) -> HttpResponse { account_import: get_config()["import"].as_bool().unwrap(), custom_songs: !crate::router::custom_song::disabled(), custom_cards: !crate::router::custom_card::disabled(), + custom_3dmv: !crate::router::custom_3dmv::disabled(), links: { global: args.global_android, japan: args.japan_android, @@ -451,6 +452,22 @@ pub fn custom_card_limits(req: HttpRequest) -> HttpResponse { .body(jzon::stringify(resp)) } +pub fn custom_3dmv_limits(req: HttpRequest) -> HttpResponse { + if crate::router::custom_3dmv::disabled() { + return HttpResponse::NotFound().finish(); + } + if session_uid(&req).is_none() { + return error("Not logged in"); + } + let resp = object!{ + result: "OK", + data: crate::router::custom_3dmv::upload_limits() + }; + HttpResponse::Ok() + .insert_header(ContentType::json()) + .body(jzon::stringify(resp)) +} + pub fn my_scopes(req: HttpRequest) -> HttpResponse { let Some(uid) = session_uid(&req) else { return error("Not logged in"); @@ -463,6 +480,7 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse { can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD), can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH), can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT), + can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT), can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) || permissions::has(uid, permissions::PERMISSION_REVOKE), can_manage_announcements: permissions::has(uid, permissions::ANNOUNCEMENT_MANAGE) diff --git a/src/runtime.rs b/src/runtime.rs index c854b18..34f21cc 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -25,6 +25,7 @@ pub struct HostConfig { pub en_android_asset_hash: String, pub enable_custom_songs: bool, pub enable_custom_cards: bool, + pub enable_custom_3dmv: bool, } // Lets an embedding app (or the tests) enable the opt-in custom songs feature @@ -37,6 +38,10 @@ pub fn set_enable_custom_cards(enabled: bool) { HOST_CONFIG.write().unwrap().enable_custom_cards = enabled; } +pub fn set_enable_custom_3dmv(enabled: bool) { + HOST_CONFIG.write().unwrap().enable_custom_3dmv = enabled; +} + // The --owner uids: the permission system's bootstrap grantors. Process-level // state rather than db rows so they work on a fresh install and can't be // revoked through the webui @@ -162,14 +167,17 @@ pub fn overlay_args(args: &mut crate::options::Args) { } overlay_str!(jp_android_asset_hash); overlay_str!(en_android_asset_hash); - // Overlay only ever enables the features; a command-line --enable-custom-songs - // / --enable-custom-cards is never overridden back to off + // Overlay only ever enables the features; a command-line --enable-custom-* + // flag is never overridden back to off if cfg.enable_custom_songs { args.enable_custom_songs = true; } if cfg.enable_custom_cards { args.enable_custom_cards = true; } + if cfg.enable_custom_3dmv { + args.enable_custom_3dmv = true; + } } // idk why an ai put tests here but they are here now. Yay tests???? @@ -192,5 +200,6 @@ pub fn lock_test_data_path() -> std::sync::MutexGuard<'static, ()> { // while holding the lock set_enable_custom_songs(true); set_enable_custom_cards(true); + set_enable_custom_3dmv(true); guard } diff --git a/webui b/webui index 700dae6..c17a5b0 160000 --- a/webui +++ b/webui @@ -1 +1 @@ -Subproject commit 700dae60a30760f99c7687337359aab295cc654d +Subproject commit c17a5b00dc3511f37e66e3b85c3eb8d7b5cdf1c9