mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-10-11 17:14:30 +08:00
Add support for custom groups
This commit is contained in:
@@ -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::custom_song::audio;
|
||||
use crate::database::custom_card as database;
|
||||
use crate::database::custom_group;
|
||||
use crate::database::permissions;
|
||||
use crate::runtime::get_data_path;
|
||||
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
|
||||
// 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
|
||||
async fn list(Login(key): Login) -> impl Responder {
|
||||
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);
|
||||
@@ -713,9 +714,17 @@ async fn list(Login(key): Login) -> impl Responder {
|
||||
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!{
|
||||
"revision": database::get_revision(),
|
||||
"groups": custom_group::list(),
|
||||
"characters": characters,
|
||||
"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
|
||||
// imported characters carry. Numbers, not enum names
|
||||
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 [
|
||||
("character_name", "Character 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,
|
||||
"school_grade": CHARACTER_SCHOOL_GRADE,
|
||||
"chara_category": CHARACTER_CHARA_CATEGORY,
|
||||
"master_group_id": CHARACTER_GROUP_ID,
|
||||
"master_group_id": group_id,
|
||||
"sprite_name": "",
|
||||
"display_order": master_character_id,
|
||||
"height": text("height"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use std::sync::Mutex;
|
||||
|
||||
use crate::router::{global, rich_text, userdata, webui, Login, Api};
|
||||
use crate::database::custom_song as database;
|
||||
use crate::database::custom_group;
|
||||
use crate::runtime::get_data_path;
|
||||
use crate::lock_onto_mutex;
|
||||
|
||||
@@ -39,6 +40,18 @@ use crate::lock_onto_mutex;
|
||||
// Shock.BAND_CATEGORY enum names
|
||||
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
|
||||
const LEVEL_COUNT: i64 = 4;
|
||||
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
|
||||
// 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() {
|
||||
// As if the endpoint doesn't exist - the client treats this as feature-off
|
||||
return Api(None);
|
||||
}
|
||||
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
||||
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() {
|
||||
// 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
|
||||
@@ -149,6 +169,7 @@ async fn list(Login(key): Login) -> impl Responder {
|
||||
}
|
||||
Api(Some(object!{
|
||||
"revision": database::get_revision(),
|
||||
"groups": custom_group::list(),
|
||||
"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()) {
|
||||
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(
|
||||
&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"),
|
||||
"attribute": attribute,
|
||||
"band_category": band_category.clone(),
|
||||
"master_group_id": master_group_id,
|
||||
"bpm": field_f64(fields, "bpm"),
|
||||
"preview_start_sec": field_f64(fields, "preview_start_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_en": field_str(fields, "artist_en"),
|
||||
"band_category": band_category.clone(),
|
||||
"master_group_id": database::band_group_id(&band_category),
|
||||
"master_group_id": master_group_id,
|
||||
"attribute": attribute,
|
||||
"bpm": field_f64(fields, "bpm").unwrap_or(DEFAULT_BPM) as f32,
|
||||
"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()) {
|
||||
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
|
||||
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"),
|
||||
"attribute": attribute,
|
||||
"band_category": band_category.clone(),
|
||||
"master_group_id": master_group_id,
|
||||
"bpm": number("bpm"),
|
||||
"preview_start_sec": preview_start_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_en": text("artist_en"),
|
||||
"band_category": band_category.clone(),
|
||||
"master_group_id": database::band_group_id(&band_category),
|
||||
"master_group_id": master_group_id,
|
||||
"attribute": attribute,
|
||||
"bpm": number("bpm").unwrap_or(DEFAULT_BPM) as f32,
|
||||
"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
|
||||
// empty, and the webui config the client gates its nav on reports it off.
|
||||
// When enabled everything works.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
// 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() {
|
||||
let Some(level) = data["level"].as_i64() else { continue; };
|
||||
if !data["level_number"].is_null() {
|
||||
|
||||
@@ -483,6 +483,7 @@ pub fn my_scopes(req: HttpRequest) -> HttpResponse {
|
||||
scopes: permissions::get_user_permissions(uid),
|
||||
can_upload_cards: permissions::has(uid, permissions::CARD_UPLOAD),
|
||||
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_3dmv: permissions::has(uid, permissions::MV_EDIT),
|
||||
can_manage_permissions: permissions::has(uid, permissions::PERMISSION_GRANT)
|
||||
|
||||
Reference in New Issue
Block a user