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); } }