mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
Oh
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
217
src/database/custom_3dmv.rs
Normal file
217
src/database/custom_3dmv.rs
Normal file
@@ -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::<i64>().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::<i64>().unwrap_or(0);
|
||||
let max = DATABASE.lock_and_select("SELECT MAX(mv_id) FROM mvs", params!()).unwrap_or_default().parse::<i64>().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<JsonValue> {
|
||||
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<i64> {
|
||||
DATABASE.lock_and_select("SELECT owner_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
pub fn get_mv_music_id(mv_id: i64) -> Option<i64> {
|
||||
DATABASE.lock_and_select("SELECT music_id FROM mvs WHERE mv_id=?1", params!(mv_id)).ok()?.parse::<i64>().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::<i64>("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<i64> {
|
||||
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<i64> = 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::<Vec<_>>().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<JsonValue> {
|
||||
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)
|
||||
}
|
||||
@@ -257,6 +257,14 @@ pub fn public_song_title(music_id: i64, english: bool) -> Option<String> {
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<i64>,
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
1504
src/router/custom_3dmv.rs
Normal file
1504
src/router/custom_3dmv.rs
Normal file
File diff suppressed because it is too large
Load Diff
88
src/router/custom_3dmv/package.rs
Normal file
88
src/router/custom_3dmv/package.rs
Normal file
@@ -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(())
|
||||
}
|
||||
121
src/router/custom_3dmv/vmd.rs
Normal file
121
src/router/custom_3dmv/vmd.rs
Normal file
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user