diff --git a/src/database.rs b/src/database.rs index 0bfc520..4cdde79 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,6 +1,7 @@ pub mod gree; pub mod custom_song; pub mod custom_card; +pub mod custom_group; pub mod custom_3dmv; pub mod permissions; pub mod announcements; diff --git a/src/database/custom_group.rs b/src/database/custom_group.rs new file mode 100644 index 0000000..da5e5ee --- /dev/null +++ b/src/database/custom_group.rs @@ -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 { + 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(); + } +} diff --git a/src/database/permissions.rs b/src/database/permissions.rs index e34161c..0068170 100644 --- a/src/database/permissions.rs +++ b/src/database/permissions.rs @@ -15,6 +15,7 @@ pub const CARD: &str = "card"; pub const CARD_UPLOAD: &str = "card.upload"; pub const CARD_PUBLISH: &str = "card.publish"; pub const CARD_EDIT: &str = "card.edit"; +pub const GROUP_MANAGE: &str = "group.manage"; pub const PERMISSION: &str = "permission"; pub const PERMISSION_GRANT: &str = "permission.grant"; @@ -31,6 +32,7 @@ pub const MV_EDIT: &str = "3dmv.edit"; pub const SCOPES: &[&str] = &[ ALL, CARD, CARD_UPLOAD, CARD_PUBLISH, CARD_EDIT, + GROUP_MANAGE, PERMISSION, PERMISSION_GRANT, PERMISSION_REVOKE, ANNOUNCEMENT, ANNOUNCEMENT_MANAGE, MV, MV_EDIT diff --git a/src/router.rs b/src/router.rs index 12f2b63..6d11397 100644 --- a/src/router.rs +++ b/src/router.rs @@ -24,6 +24,7 @@ pub mod card; pub mod shop; pub mod custom_song; pub mod custom_card; +pub mod custom_group; pub mod custom_3dmv; pub mod rich_text; 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_card::web_routes); + cfg.configure(custom_group::web_routes); cfg.configure(custom_3dmv::web_routes); cfg.configure(web::routes); } diff --git a/src/router/custom_card.rs b/src/router/custom_card.rs index 33e480b..003dce7 100644 --- a/src/router/custom_card.rs +++ b/src/router/custom_card.rs @@ -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 { // 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 { + 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::().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"), diff --git a/src/router/custom_group.rs b/src/router/custom_group.rs new file mode 100644 index 0000000..24ee605 --- /dev/null +++ b/src/router/custom_group.rs @@ -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, 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, 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); + } +} diff --git a/src/router/custom_song.rs b/src/router/custom_song.rs index d0e7127..17a1906 100644 --- a/src/router/custom_song.rs +++ b/src/router/custom_song.rs @@ -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 { + if value.is_empty() || value == "0" { + return Ok(database::band_group_id(band_category)); + } + let id = value.parse::().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>) -> Result>) -> Result>) -> Result>) -> 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>) -> 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>) -> 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. diff --git a/src/router/custom_song/package.rs b/src/router/custom_song/package.rs index 8efbb9b..5dbef83 100644 --- a/src/router/custom_song/package.rs +++ b/src/router/custom_song/package.rs @@ -98,6 +98,9 @@ pub fn expand(package: &[u8], fields: &mut HashMap>) -> 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() { diff --git a/src/router/webui.rs b/src/router/webui.rs index 0e83ad4..48e1775 100644 --- a/src/router/webui.rs +++ b/src/router/webui.rs @@ -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)