mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-10-11 17:14:30 +08:00
Oh
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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<Vec<u8>, 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<R: Read + Seek>(archive: &mut zip::ZipArchive<R>, name: &str) -> Option<Vec<u8>> {
|
||||
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<String, Vec<u8>>) -> 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(())
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user