Add support for custom groups

This commit is contained in:
Ethan O'Brien
2026-09-24 19:17:08 -05:00
parent 8270662666
commit dfc29c70e4
9 changed files with 332 additions and 6 deletions
+1
View File
@@ -1,6 +1,7 @@
pub mod gree; pub mod gree;
pub mod custom_song; pub mod custom_song;
pub mod custom_card; pub mod custom_card;
pub mod custom_group;
pub mod custom_3dmv; pub mod custom_3dmv;
pub mod permissions; pub mod permissions;
pub mod announcements; pub mod announcements;
+76
View File
@@ -0,0 +1,76 @@
use lazy_static::lazy_static;
use rusqlite::params;
use jzon::{array, object, JsonValue};
use crate::sql::SQLite;
lazy_static! {
static ref DATABASE: SQLite = SQLite::new("custom_groups.db", setup_tables);
}
pub const FIRST_ID: i64 = 10_000;
fn setup_tables(conn: &rusqlite::Connection) {
conn.execute_batch("CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS custom_groups_unique_name ON groups(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS logos (group_id INTEGER PRIMARY KEY, md5 TEXT NOT NULL, size INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS sequence (id INTEGER PRIMARY KEY CHECK (id=1), last_id INTEGER NOT NULL);").unwrap();
}
pub fn list() -> JsonValue {
let conn = rusqlite::Connection::open(DATABASE.get_path()).unwrap();
let Ok(mut stmt) = conn.prepare("SELECT id, name, name_en, COALESCE(md5, ''), COALESCE(size, 0) FROM groups LEFT JOIN logos ON group_id=id ORDER BY id") else { return array![]; };
let Ok(rows) = stmt.query_map(params![], |row| Ok(object! {
"id": row.get::<_, i64>(0)?,
"name": row.get::<_, String>(1)?,
"name_en": row.get::<_, String>(2)?,
"logo_md5": row.get::<_, String>(3)?,
"logo_size": row.get::<_, i64>(4)?
})) else { return array![]; };
let mut result = array![];
for row in rows.flatten() { result.push(row).unwrap(); }
result
}
pub fn set_logo(id: i64, md5: &str, size: i64) -> Result<(), rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
conn.execute("INSERT INTO logos (group_id, md5, size) VALUES (?1, ?2, ?3) ON CONFLICT(group_id) DO UPDATE SET md5=excluded.md5, size=excluded.size", params!(id, md5, size))?;
Ok(())
})
}
pub fn has_logo(md5: &str) -> bool {
DATABASE.lock_and_select("SELECT group_id FROM logos WHERE md5=?1", params!(md5)).is_ok()
}
pub fn exists(id: i64) -> bool {
if id < FIRST_ID { return false; }
DATABASE.lock_and_select("SELECT id FROM groups WHERE id=?1", params!(id)).is_ok()
}
pub fn create(name: &str, name_en: &str) -> Result<i64, rusqlite::Error> {
DATABASE.lock_and_transact(|conn| {
let last: i64 = conn.query_row("SELECT last_id FROM sequence WHERE id=1", [], |row| row.get(0)).unwrap_or(FIRST_ID - 1);
let id = last + 1;
conn.execute("INSERT INTO groups (id, name, name_en) VALUES (?1, ?2, ?3)", params!(id, name, name_en))?;
conn.execute("INSERT INTO sequence (id, last_id) VALUES (1, ?1) ON CONFLICT(id) DO UPDATE SET last_id=?1", params!(id))?;
Ok(id)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_logo_storage_to_existing_group_database() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch("CREATE TABLE groups (id INTEGER PRIMARY KEY, name TEXT NOT NULL, name_en TEXT NOT NULL);
INSERT INTO groups VALUES (10000, 'Team A', 'Team A');").unwrap();
setup_tables(&conn);
setup_tables(&conn);
assert_eq!(conn.query_row("SELECT name FROM groups WHERE id=10000", [], |row| row.get::<_, String>(0)).unwrap(), "Team A");
conn.execute("INSERT INTO logos VALUES (10000, 'test-hash', 42)", []).unwrap();
}
}
+2
View File
@@ -15,6 +15,7 @@ pub const CARD: &str = "card";
pub const CARD_UPLOAD: &str = "card.upload"; pub const CARD_UPLOAD: &str = "card.upload";
pub const CARD_PUBLISH: &str = "card.publish"; pub const CARD_PUBLISH: &str = "card.publish";
pub const CARD_EDIT: &str = "card.edit"; pub const CARD_EDIT: &str = "card.edit";
pub const GROUP_MANAGE: &str = "group.manage";
pub const PERMISSION: &str = "permission"; pub const PERMISSION: &str = "permission";
pub const PERMISSION_GRANT: &str = "permission.grant"; pub const PERMISSION_GRANT: &str = "permission.grant";
@@ -31,6 +32,7 @@ pub const MV_EDIT: &str = "3dmv.edit";
pub const SCOPES: &[&str] = &[ pub const SCOPES: &[&str] = &[
ALL, ALL,
CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT,
GROUP_MANAGE,
PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE, PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE,
ANNOUNCEMENT, ANNOUNCEMENT_MANAGE, ANNOUNCEMENT, ANNOUNCEMENT_MANAGE,
MV, MV_EDIT MV, MV_EDIT
+2
View File
@@ -24,6 +24,7 @@ pub mod card;
pub mod shop; pub mod shop;
pub mod custom_song; pub mod custom_song;
pub mod custom_card; pub mod custom_card;
pub mod custom_group;
pub mod custom_3dmv; pub mod custom_3dmv;
pub mod rich_text; pub mod rich_text;
pub mod webui; pub mod webui;
@@ -301,6 +302,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
); );
cfg.configure(custom_song::web_routes); cfg.configure(custom_song::web_routes);
cfg.configure(custom_card::web_routes); cfg.configure(custom_card::web_routes);
cfg.configure(custom_group::web_routes);
cfg.configure(custom_3dmv::web_routes); cfg.configure(custom_3dmv::web_routes);
cfg.configure(web::routes); cfg.configure(web::routes);
} }
+19 -3
View File
@@ -13,6 +13,7 @@ use crate::router::{databases, global, rich_text, userdata, webui, Login, Api};
use crate::router::databases::csv::{table, Region}; use crate::router::databases::csv::{table, Region};
use crate::router::custom_song::audio; use crate::router::custom_song::audio;
use crate::database::custom_card as database; use crate::database::custom_card as database;
use crate::database::custom_group;
use crate::database::permissions; use crate::database::permissions;
use crate::runtime::get_data_path; use crate::runtime::get_data_path;
use crate::lock_onto_mutex; use crate::lock_onto_mutex;
@@ -700,7 +701,7 @@ pub fn owned_runtime_ids(user: &JsonValue) -> Vec<i64> {
// The catalog is filtered per requesting user: everyone gets the published // The catalog is filtered per requesting user: everyone gets the published
// cards, the owner additionally gets their drafts, and a game account that // cards, the owner additionally gets their drafts, and a game account that
// already owns a card keeps resolving it even if it was since unpublished // already owns a card keeps resolving it even if it was since unpublished
async fn list(Login(key): Login) -> impl Responder { async fn list(req: HttpRequest, Login(key): Login) -> impl Responder {
if disabled() { if disabled() {
// As if the endpoint doesn't exist - the client treats this as feature-off // As if the endpoint doesn't exist - the client treats this as feature-off
return Api(None); return Api(None);
@@ -713,9 +714,17 @@ async fn list(Login(key): Login) -> impl Responder {
nerf_clamp_card(card); nerf_clamp_card(card);
} }
} }
let characters = database::get_characters_for_cards(uid, &cards); let mut characters = database::get_characters_for_cards(uid, &cards);
if global::client_protocol_version(&req) < crate::router::custom_group::PROTOCOL_VERSION {
for character in characters.members_mut() {
if character["master_group_id"].as_i64().unwrap_or(0) >= custom_group::FIRST_ID {
character["master_group_id"] = CHARACTER_GROUP_ID.into();
}
}
}
Api(Some(object!{ Api(Some(object!{
"revision": database::get_revision(), "revision": database::get_revision(),
"groups": custom_group::list(),
"characters": characters, "characters": characters,
"cards": cards "cards": cards
})) }))
@@ -1352,6 +1361,13 @@ fn valid_color(color: &str) -> bool {
// Every column the uploader never supplies is forced to the value all 172 // Every column the uploader never supplies is forced to the value all 172
// imported characters carry. Numbers, not enum names // imported characters carry. Numbers, not enum names
fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> { fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
let group_text = text_of(fields, "character_master_group_id", stored, "master_group_id");
let group_id = if group_text.is_empty() { CHARACTER_GROUP_ID } else {
group_text.parse::<i64>().map_err(|_| String::from("Invalid group ID"))?
};
if group_id != CHARACTER_GROUP_ID && !custom_group::exists(group_id) {
return Err(String::from("Custom group does not exist"));
}
for (key, label) in [ for (key, label) in [
("character_name", "Character name"), ("character_name", "Character name"),
("character_name_en", "Character English name"), ("character_name_en", "Character English name"),
@@ -1398,7 +1414,7 @@ fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue
"category": CHARACTER_CATEGORY_OTHER, "category": CHARACTER_CATEGORY_OTHER,
"school_grade": CHARACTER_SCHOOL_GRADE, "school_grade": CHARACTER_SCHOOL_GRADE,
"chara_category": CHARACTER_CHARA_CATEGORY, "chara_category": CHARACTER_CHARA_CATEGORY,
"master_group_id": CHARACTER_GROUP_ID, "master_group_id": group_id,
"sprite_name": "", "sprite_name": "",
"display_order": master_character_id, "display_order": master_character_id,
"height": text("height"), "height": text("height"),
+181
View File
@@ -0,0 +1,181 @@
use actix_web::{web, HttpRequest, HttpResponse, http::header::ContentType};
use jzon::{object, JsonValue};
use crate::database::{custom_group, permissions};
use crate::router::{userdata, webui, rich_text};
use std::io::Cursor;
use futures_util::StreamExt;
pub const PROTOCOL_VERSION: u32 = 6;
pub fn web_routes(cfg: &mut web::ServiceConfig) {
cfg.service(web::scope("/custom_group")
.route("/list", web::get().to(list))
.route("/create", web::post().to(create))
.route("/{id}/logo", web::post().to(upload_logo))
.route("/data/{hash}/{file}", web::get().to(logo_data)));
}
fn can_manage(req: &HttpRequest) -> bool {
webui::get_login_token(req)
.and_then(|token| userdata::webui_login_token(&token))
.and_then(|key| userdata::get_acc(&key)["user"]["id"].as_i64())
.is_some_and(|id| permissions::has(id, permissions::GROUP_MANAGE))
}
fn encode_logo(bytes: &[u8]) -> Result<Vec<u8>, String> {
let mut reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format().map_err(|_| "Invalid image")?;
if !matches!(reader.format(), Some(image::ImageFormat::Png | image::ImageFormat::Jpeg)) {
return Err("Logo must be PNG or JPEG".into());
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(64 * 1024 * 1024);
reader.limits(limits);
let image = reader.decode().map_err(|_| "Invalid or oversized image")?.thumbnail(512, 256);
let mut png = Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).map_err(|_| "Could not encode logo")?;
Ok(png.into_inner())
}
async fn upload_logo(req: HttpRequest, id: web::Path<i64>, mut payload: web::Payload) -> HttpResponse {
if !can_manage(&req) { return HttpResponse::Forbidden().finish(); }
if !custom_group::exists(*id) { return HttpResponse::NotFound().finish(); }
let mut bytes = Vec::new();
while let Some(chunk) = payload.next().await {
let Ok(chunk) = chunk else { return HttpResponse::BadRequest().finish(); };
if bytes.len() + chunk.len() > 4 * 1024 * 1024 { return HttpResponse::PayloadTooLarge().finish(); }
bytes.extend_from_slice(&chunk);
}
let png = match web::block(move || encode_logo(&bytes)).await {
Ok(Ok(png)) => png,
Ok(Err(message)) => return reply(object! { "result": "ERR", "message": message }),
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let hash = format!("{:x}", md5::compute(&png));
let dir = crate::runtime::get_data_path("custom_groups");
if store_logo(&dir, &hash, &png).is_err()
|| custom_group::set_logo(*id, &hash, png.len() as i64).is_err() {
return HttpResponse::InternalServerError().finish();
}
crate::database::custom_song::bump_revision();
crate::database::custom_card::bump_revision();
reply(object! { "result": "OK", "logo_md5": hash, "logo_size": png.len() })
}
fn store_logo(dir: &str, hash: &str, png: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let destination = format!("{dir}/{hash}.png");
if std::fs::read(&destination).is_ok_and(|bytes| bytes == png) { return Ok(()); }
let temporary = format!("{dir}/{}.tmp", uuid::Uuid::now_v7());
let result = std::fs::write(&temporary, png).and_then(|_| std::fs::rename(&temporary, destination));
if result.is_err() { let _ = std::fs::remove_file(temporary); }
result
}
async fn logo_data(path: web::Path<(String, String)>) -> HttpResponse {
let (hash, file) = path.into_inner();
if hash.len() != 32 || !hash.bytes().all(|b| b.is_ascii_hexdigit())
|| file != format!("{hash}.png") || !custom_group::has_logo(&hash) {
return HttpResponse::NotFound().finish();
}
match std::fs::read(crate::runtime::get_data_path(&format!("custom_groups/{hash}.png"))) {
Ok(bytes) if format!("{:x}", md5::compute(&bytes)) == hash => HttpResponse::Ok()
.insert_header(("Cache-Control", "public, max-age=31536000, immutable"))
.content_type("image/png").body(bytes),
_ => HttpResponse::NotFound().finish(),
}
}
fn reply(value: JsonValue) -> HttpResponse {
HttpResponse::Ok().insert_header(ContentType::json()).body(jzon::stringify(value))
}
async fn list() -> HttpResponse {
reply(object! { "result": "OK", "groups": custom_group::list() })
}
async fn create(req: HttpRequest, body: String) -> HttpResponse {
if !can_manage(&req) {
return reply(object! { "result": "ERR", "message": "You do not have permission to create groups" });
}
let Ok(data) = jzon::parse(&body) else {
return reply(object! { "result": "ERR", "message": "Invalid JSON" });
};
let name = data["name"].as_str().unwrap_or("").trim();
let name_en = data["name_en"].as_str().unwrap_or("").trim();
if name.is_empty() || name.chars().count() > 60 || name_en.chars().count() > 60 {
return reply(object! { "result": "ERR", "message": "Group name must be 1-60 characters (English name at most 60)" });
}
if let Err(message) = rich_text::reject_tags("Group name", name, &[]) {
return reply(object! { "result": "ERR", "message": message });
}
if let Err(message) = rich_text::reject_tags("English group name", name_en, &[]) {
return reply(object! { "result": "ERR", "message": message });
}
match custom_group::create(name, name_en) {
Ok(id) => reply(object! { "result": "OK", "id": id }),
Err(_) => reply(object! { "result": "ERR", "message": "A group with that name already exists, or it could not be stored" })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logo_preserves_aspect_and_transparency() {
let source = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(1024, 256, image::Rgba([1, 2, 3, 64])));
let mut input = Cursor::new(Vec::new());
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
let output = encode_logo(input.get_ref()).unwrap();
let decoded = image::load_from_memory(&output).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), (512, 128));
assert_eq!(decoded.get_pixel(0, 0).0[3], 64);
assert!(encode_logo(b"not an image").is_err());
}
#[test]
fn rejects_oversized_dimensions() {
let source = image::DynamicImage::new_rgba8(4097, 1);
let mut input = Cursor::new(Vec::new());
source.write_to(&mut input, image::ImageFormat::Png).unwrap();
assert!(encode_logo(input.get_ref()).is_err());
}
#[actix_web::test]
async fn logo_upload_requires_group_permission() {
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
let request = actix_web::test::TestRequest::post().uri("/custom_group/10000/logo").set_payload("invalid").to_request();
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::FORBIDDEN);
}
#[actix_web::test]
async fn logo_catalog_and_content_addressed_download() {
let _lock = crate::runtime::lock_test_data_path();
let id = custom_group::create(&format!("Logo test {}", uuid::Uuid::now_v7()), "").unwrap();
let mut input = Cursor::new(Vec::new());
image::DynamicImage::new_rgba8(32, 16).write_to(&mut input, image::ImageFormat::Png).unwrap();
let png = encode_logo(input.get_ref()).unwrap();
let hash = format!("{:x}", md5::compute(&png));
let dir = crate::runtime::get_data_path("custom_groups");
store_logo(&dir, &hash, &png).unwrap();
store_logo(&dir, &hash, &png).unwrap();
custom_group::set_logo(id, &hash, png.len() as i64).unwrap();
let catalog = custom_group::list();
let row = catalog.members().find(|g| g["id"].as_i64() == Some(id)).unwrap();
assert_eq!(row["logo_md5"].as_str(), Some(hash.as_str()));
assert_eq!(row["logo_size"].as_usize(), Some(png.len()));
let app = actix_web::test::init_service(actix_web::App::new().configure(web_routes)).await;
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
let response = actix_web::test::call_service(&app, request).await;
assert_eq!(response.status(), actix_web::http::StatusCode::OK);
assert_eq!(actix_web::test::read_body(response).await.as_ref(), png);
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/wrong.png")).to_request();
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
std::fs::write(format!("{dir}/{hash}.png"), b"corrupted").unwrap();
let request = actix_web::test::TestRequest::get().uri(&format!("/custom_group/data/{hash}/{hash}.png")).to_request();
assert_eq!(actix_web::test::call_service(&app, request).await.status(), actix_web::http::StatusCode::NOT_FOUND);
}
}
+47 -3
View File
@@ -18,6 +18,7 @@ use std::sync::Mutex;
use crate::router::{global, rich_text, userdata, webui, Login, Api}; use crate::router::{global, rich_text, userdata, webui, Login, Api};
use crate::database::custom_song as database; use crate::database::custom_song as database;
use crate::database::custom_group;
use crate::runtime::get_data_path; use crate::runtime::get_data_path;
use crate::lock_onto_mutex; use crate::lock_onto_mutex;
@@ -39,6 +40,18 @@ use crate::lock_onto_mutex;
// Shock.BAND_CATEGORY enum names // Shock.BAND_CATEGORY enum names
const BAND_CATEGORIES: &[&str] = &["NONE", "MUSE", "AQOURS", "NIJIGAKU", "LIELLA", "HASUNOSORA", "OTHER", "YOHANE"]; const BAND_CATEGORIES: &[&str] = &["NONE", "MUSE", "AQOURS", "NIJIGAKU", "LIELLA", "HASUNOSORA", "OTHER", "YOHANE"];
fn selected_group_id(value: &str, band_category: &mut String) -> Result<i64, String> {
if value.is_empty() || value == "0" {
return Ok(database::band_group_id(band_category));
}
let id = value.parse::<i64>().map_err(|_| String::from("Invalid group ID"))?;
if !custom_group::exists(id) {
return Err(String::from("Custom group does not exist"));
}
*band_category = String::from("OTHER");
Ok(id)
}
// NORMAL, HARD, EXPERT, MASTER // NORMAL, HARD, EXPERT, MASTER
const LEVEL_COUNT: i64 = 4; const LEVEL_COUNT: i64 = 4;
const DEFAULT_LEVEL_NUMBERS: &[i64] = &[3, 6, 9, 12]; const DEFAULT_LEVEL_NUMBERS: &[i64] = &[3, 6, 9, 12];
@@ -129,13 +142,20 @@ pub fn client_supports_custom_songs(req: &HttpRequest) -> bool {
// The catalog is filtered per requesting user: private songs only show for // The catalog is filtered per requesting user: private songs only show for
// their owner, shared songs for the owner plus their shared-user list // their owner, shared songs for the owner plus their shared-user list
async fn list(Login(key): Login) -> impl Responder { async fn list(req: HttpRequest, Login(key): Login) -> impl Responder {
if disabled() { if disabled() {
// As if the endpoint doesn't exist - the client treats this as feature-off // As if the endpoint doesn't exist - the client treats this as feature-off
return Api(None); return Api(None);
} }
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap(); let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
let mut songs = database::get_songs_for_user(uid); let mut songs = database::get_songs_for_user(uid);
if global::client_protocol_version(&req) < crate::router::custom_group::PROTOCOL_VERSION {
for song in songs.members_mut() {
if song["master_group_id"].as_i64().unwrap_or(0) >= custom_group::FIRST_ID {
song["master_group_id"] = database::band_group_id("OTHER").into();
}
}
}
for song in songs.members_mut() { for song in songs.members_mut() {
// Additive field: the client turns it into the song's detail-info credit line (the // Additive field: the client turns it into the song's detail-info credit line (the
// staff-credits text the live loading screen and the music library show). Old clients // staff-credits text the live loading screen and the music library show). Old clients
@@ -149,6 +169,7 @@ async fn list(Login(key): Login) -> impl Responder {
} }
Api(Some(object!{ Api(Some(object!{
"revision": database::get_revision(), "revision": database::get_revision(),
"groups": custom_group::list(),
"songs": songs "songs": songs
})) }))
} }
@@ -649,6 +670,7 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
if !BAND_CATEGORIES.contains(&band_category.as_str()) { if !BAND_CATEGORIES.contains(&band_category.as_str()) {
return Err(format!("Unknown band category '{}'", band_category)); return Err(format!("Unknown band category '{}'", band_category));
} }
let master_group_id = selected_group_id(&field_str(fields, "master_group_id"), &mut band_category)?;
validate_song_text( validate_song_text(
&name, &name,
@@ -746,6 +768,7 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
"artist_en": field_str(fields, "artist_en"), "artist_en": field_str(fields, "artist_en"),
"attribute": attribute, "attribute": attribute,
"band_category": band_category.clone(), "band_category": band_category.clone(),
"master_group_id": master_group_id,
"bpm": field_f64(fields, "bpm"), "bpm": field_f64(fields, "bpm"),
"preview_start_sec": field_f64(fields, "preview_start_sec"), "preview_start_sec": field_f64(fields, "preview_start_sec"),
"preview_length_sec": field_f64(fields, "preview_length_sec"), "preview_length_sec": field_f64(fields, "preview_length_sec"),
@@ -761,7 +784,7 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
"artist": artist, "artist": artist,
"artist_en": field_str(fields, "artist_en"), "artist_en": field_str(fields, "artist_en"),
"band_category": band_category.clone(), "band_category": band_category.clone(),
"master_group_id": database::band_group_id(&band_category), "master_group_id": master_group_id,
"attribute": attribute, "attribute": attribute,
"bpm": field_f64(fields, "bpm").unwrap_or(DEFAULT_BPM) as f32, "bpm": field_f64(fields, "bpm").unwrap_or(DEFAULT_BPM) as f32,
"start_wait": 2.0, "start_wait": 2.0,
@@ -876,6 +899,13 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
if !BAND_CATEGORIES.contains(&band_category.as_str()) { if !BAND_CATEGORIES.contains(&band_category.as_str()) {
return Err(format!("Unknown band category '{}'", band_category)); return Err(format!("Unknown band category '{}'", band_category));
} }
let group_text = if fields.contains_key("master_group_id") {
field_str(fields, "master_group_id")
} else {
old_manifest["master_group_id"].as_i64().filter(|id| *id >= custom_group::FIRST_ID)
.map(|id| id.to_string()).unwrap_or_default()
};
let master_group_id = selected_group_id(&group_text, &mut band_category)?;
// The RESULTING text, so an edit that leaves a field alone is checked against what stays // The RESULTING text, so an edit that leaves a field alone is checked against what stays
validate_song_text( validate_song_text(
@@ -1023,6 +1053,7 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
"artist_en": text("artist_en"), "artist_en": text("artist_en"),
"attribute": attribute, "attribute": attribute,
"band_category": band_category.clone(), "band_category": band_category.clone(),
"master_group_id": master_group_id,
"bpm": number("bpm"), "bpm": number("bpm"),
"preview_start_sec": preview_start_sec, "preview_start_sec": preview_start_sec,
"preview_length_sec": preview_length_sec, "preview_length_sec": preview_length_sec,
@@ -1047,7 +1078,7 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
"artist": artist, "artist": artist,
"artist_en": text("artist_en"), "artist_en": text("artist_en"),
"band_category": band_category.clone(), "band_category": band_category.clone(),
"master_group_id": database::band_group_id(&band_category), "master_group_id": master_group_id,
"attribute": attribute, "attribute": attribute,
"bpm": number("bpm").unwrap_or(DEFAULT_BPM) as f32, "bpm": number("bpm").unwrap_or(DEFAULT_BPM) as f32,
"start_wait": 2.0, "start_wait": 2.0,
@@ -2127,6 +2158,19 @@ mod tests {
} }
} }
#[test]
fn custom_group_forces_other_band_and_rejects_unknown_ids() {
let _lock = crate::runtime::lock_test_data_path();
let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let id = custom_group::create(&format!("Test {}", unique), "Test Band").unwrap();
assert!(custom_group::exists(id));
let mut band = String::from("MUSE");
assert_eq!(selected_group_id(&id.to_string(), &mut band), Ok(id));
assert_eq!(band, "OTHER");
assert!(selected_group_id("999999999", &mut band).is_err());
assert_eq!(selected_group_id("", &mut band), Ok(9999));
}
// The whole feature is off unless --enable-custom-songs: endpoints 404 / go // The whole feature is off unless --enable-custom-songs: endpoints 404 / go
// empty, and the webui config the client gates its nav on reports it off. // empty, and the webui config the client gates its nav on reports it off.
// When enabled everything works. // When enabled everything works.
+3
View File
@@ -98,6 +98,9 @@ pub fn expand(package: &[u8], fields: &mut HashMap<String, Vec<u8>>) -> Result<(
fields.insert(key.to_string(), manifest[key].to_string().into_bytes()); fields.insert(key.to_string(), manifest[key].to_string().into_bytes());
} }
} }
// Group IDs are server-local. The package records the original ID for
// reference, but only the destination upload form may choose its group;
// copying the ID could silently select a different band's row there.
for data in manifest["levels"].members() { for data in manifest["levels"].members() {
let Some(level) = data["level"].as_i64() else { continue; }; let Some(level) = data["level"].as_i64() else { continue; };
if !data["level_number"].is_null() { if !data["level_number"].is_null() {
+1
View File
@@ -483,6 +483,7 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
scopes: permissions::get_user_permissions(uid), scopes: permissions::get_user_permissions(uid),
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD), can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH), can_publish_cards: permissions::has(uid, permissions::CARD_PUBLISH),
can_manage_groups: permissions::has(uid, permissions::GROUP_MANAGE),
can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT), can_edit_any_cards: permissions::has(uid, permissions::CARD_EDIT),
can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT), can_edit_any_3dmv: permissions::has(uid, permissions::MV_EDIT),
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT) can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)