From 74d2ada067bd2fd9436710ab7ac260e0eddf1314 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Jul 2026 01:03:24 +0200 Subject: Add per-instance mod management with client-side detection - New mod validation engine (src/mods.rs) with constant-pool class-file scanning: detects server-side content (block, tileentity, command, worldgen) vs client-only API (gui, renderer, forge/client) - 7 client-only mods correctly detected (AppleSkin, CraftingTweaks, Durability101, EnchantmentDescriptions, InventoryHUD, MouseTweaks, TrashSlot); 51 server-capable; 1 unknown - Routes + templating for mod upload (jar/zip), listing, delete, rescan - Multipart upload with 500MB limit (bypasses axum's default 2MB) - Per-instance mod summary card on edit page - Whitelist toggle checkbox on edit page (persisted, drives ENABLE_WHITELIST) - Dynamic Java image tag (java8/java17/java21) based on MC version - Class scan is informational-only; rejection based on metadata side=CLIENT - has_modpack flag cleanup on mod delete - Remove hx-boost from (was corrupting multipart uploads) --- src/routes/backups.rs | 8 +- src/routes/instances.rs | 61 +++++-- src/routes/mod.rs | 2 + src/routes/mods.rs | 422 ++++++++++++++++++++++++++++++++++++++++++++++++ src/routes/players.rs | 5 +- 5 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 src/routes/mods.rs (limited to 'src/routes') diff --git a/src/routes/backups.rs b/src/routes/backups.rs index 6f7617e..d68be8d 100644 --- a/src/routes/backups.rs +++ b/src/routes/backups.rs @@ -119,14 +119,12 @@ async fn create_backup( if let Ok(entries) = std::fs::read_dir(&data_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name() { - if let Ok(contents) = std::fs::read(&path) { + if path.is_file() + && let Some(name) = path.file_name() + && let Ok(contents) = std::fs::read(&path) { let _ = zip.start_file(&*name.to_string_lossy(), options); let _ = std::io::Write::write_all(&mut zip, &contents); } - } - } } } let _ = zip.finish(); diff --git a/src/routes/instances.rs b/src/routes/instances.rs index 8dd6a62..f1c3e37 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs @@ -41,6 +41,8 @@ struct EditInstanceTemplate { instance: Instance, current_seed: Option, max_memory_mb: u32, + mod_count: i64, + rejected_count: i64, } struct InstanceInfo { @@ -74,6 +76,7 @@ pub struct EditInstanceForm { pub gamemode: i32, pub max_players: i32, pub pvp: Option, + pub whitelist_enabled: Option, pub motd: Option, pub memory_mb: i32, } @@ -216,8 +219,8 @@ async fn edit_form( let conn = state.db.conn(); conn.query_row( "SELECT id, name, version, server_type, world_source, world_seed, level_type, - has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, - rcon_password, is_active, created_at, updated_at + has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, whitelist_enabled, + motd, rcon_password, is_active, created_at, updated_at FROM instances WHERE id = ?1", params![id], |row| { @@ -235,11 +238,12 @@ async fn edit_form( gamemode: row.get(10)?, max_players: row.get(11)?, pvp: row.get::<_, i32>(12)? != 0, - motd: row.get(13)?, - rcon_password: row.get(14)?, - is_active: row.get::<_, i32>(15)? != 0, - created_at: row.get(16)?, - updated_at: row.get(17)?, + whitelist_enabled: row.get::<_, i32>(13)? != 0, + motd: row.get(14)?, + rcon_password: row.get(15)?, + is_active: row.get::<_, i32>(16)? != 0, + created_at: row.get(17)?, + updated_at: row.get(18)?, }) }, ) @@ -272,6 +276,26 @@ async fn edit_form( current_seed }; + let (mod_count, rejected_count) = { + let conn = state.db.conn(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mods WHERE instance_id = ?1", + params![id], + |row| row.get(0), + ) + .unwrap_or(0); + let rejected: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 0", + params![id], + |row| row.get(0), + ) + .unwrap_or(0); + drop(conn); + (total, rejected) + }; + let template = EditInstanceTemplate { username: account.username.clone(), is_superuser: account.is_superuser, @@ -279,6 +303,8 @@ async fn edit_form( instance, current_seed, max_memory_mb: state.config.max_memory_mb, + mod_count, + rejected_count, }; Ok(Html(template.render().unwrap_or_default())) @@ -295,18 +321,20 @@ async fn edit_instance( } let pvp = form.pvp.as_deref() == Some("on"); + let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); let is_active = { let conn = state.db.conn(); conn.execute( "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, - pvp = ?4, motd = ?5, memory_mb = ?6, updated_at = datetime('now') - WHERE id = ?7", + pvp = ?4, whitelist_enabled = ?5, motd = ?6, memory_mb = ?7, + updated_at = datetime('now') WHERE id = ?8", params![ form.difficulty, form.gamemode, form.max_players, pvp, + whitelist_enabled, form.motd, form.memory_mb, id, @@ -390,8 +418,8 @@ async fn activate_instance( conn.query_row( "SELECT id, name, version, server_type, world_source, world_seed, level_type, - has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, - rcon_password, is_active, created_at, updated_at + has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, whitelist_enabled, + motd, rcon_password, is_active, created_at, updated_at FROM instances WHERE id = ?1", params![id], |row| { @@ -409,11 +437,12 @@ async fn activate_instance( gamemode: row.get(10)?, max_players: row.get(11)?, pvp: row.get::<_, i32>(12)? != 0, - motd: row.get(13)?, - rcon_password: row.get(14)?, - is_active: row.get::<_, i32>(15)? != 0, - created_at: row.get(16)?, - updated_at: row.get(17)?, + whitelist_enabled: row.get::<_, i32>(13)? != 0, + motd: row.get(14)?, + rcon_password: row.get(15)?, + is_active: row.get::<_, i32>(16)? != 0, + created_at: row.get(17)?, + updated_at: row.get(18)?, }) }, ) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 1cb0e54..a4d200c 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod accounts; pub mod backups; pub mod dashboard; pub mod instances; +pub mod mods; pub mod players; use askama::Template; @@ -35,6 +36,7 @@ pub fn build_router(state: AppState) -> Router { .merge(players::router()) .merge(accounts::router()) .merge(backups::router()) + .merge(mods::router()) .layer(auth_required); Router::new() diff --git a/src/routes/mods.rs b/src/routes/mods.rs new file mode 100644 index 0000000..4716fec --- /dev/null +++ b/src/routes/mods.rs @@ -0,0 +1,422 @@ +use askama::Template; +use axum::{ + Router, + extract::{Path, State}, + http::Request, + body::Body, + response::{Html, Redirect}, + routing::{get, post}, +}; +use rusqlite::params; +use std::io::{Cursor, Read}; + +use crate::auth::CurrentUser; +use crate::mods; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "mods/list.html")] +struct ModListTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instance_name: String, + instance_id: i64, + mods: Vec, + flash: Option, +} + +struct ModRow { + id: i64, + filename: String, + display_name: Option, + side: String, + accepted: bool, + rejection_reason: Option, + created_at: String, +} + +struct FlashMessage { + kind: String, + text: String, +} + +pub fn router() -> Router { + Router::new() + .route("/instances/{id}/mods", get(list_mods).post(upload_mod)) + .route("/instances/{id}/mods/{mod_id}/delete", post(delete_mod)) + .route("/instances/{id}/mods/rescan", post(rescan_mods)) +} + +async fn list_mods( + State(state): State, + Path(instance_id): Path, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let conn = state.db.conn(); + let instance_name: String = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![instance_id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/instances"))?; + + let mut stmt = conn + .prepare( + "SELECT id, filename, display_name, side, accepted, rejection_reason, created_at + FROM mods WHERE instance_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|_| Redirect::to("/instances"))?; + + let mods: Vec = stmt + .query_map(params![instance_id], |row| { + Ok(ModRow { + id: row.get(0)?, + filename: row.get(1)?, + display_name: row.get(2)?, + side: row.get(3)?, + accepted: row.get::<_, i32>(4)? != 0, + rejection_reason: row.get(5)?, + created_at: row.get(6)?, + }) + }) + .map_err(|_| Redirect::to("/instances"))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + drop(conn); + + let template = ModListTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instance_name, + instance_id, + mods, + flash: None, + }; + + match template.render() { + Ok(html) => Ok(Html(html)), + Err(e) => { + tracing::error!("Failed to render mods template: {e}"); + Err(Redirect::to("/instances")) + } + } +} + +async fn upload_mod( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, + req: Request, +) -> Result { + tracing::info!("Upload mod request for instance {instance_id}"); + + let instance_name = match get_instance_name(&state, instance_id) { + Some(n) => n, + None => return Err(Redirect::to("/instances")), + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("mods"); + let _ = std::fs::create_dir_all(&mods_dir); + + // Build multer multipart parser with generous size limit (500MB) + let (parts, body) = req.into_parts(); + let content_type = parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + tracing::info!("Content-Type: {content_type}"); + + let boundary = content_type + .split(';') + .find_map(|part| { + let part = part.trim(); + part.strip_prefix("boundary=").map(|b| b.trim_matches('"').to_string()) + }) + .unwrap_or_default(); + + if boundary.is_empty() { + tracing::error!("No boundary found in Content-Type"); + return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))); + } + + let constraints = multer::Constraints::new() + .size_limit(multer::SizeLimit::new() + .whole_stream(500 * 1024 * 1024) + .per_field(500 * 1024 * 1024)); + let stream = body.into_data_stream(); + let mut multipart = multer::Multipart::with_constraints(stream, boundary, constraints); + + let mut processed = 0u32; + loop { + match multipart.next_field().await { + Ok(Some(field)) => { + let file_name = field + .file_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| "mod.jar".to_string()); + tracing::info!( + "Field: {:?}, file: {}, content-type: {:?}", + field.name(), + file_name, + field.content_type().map(|s| s.to_string()) + ); + + // Read field data as bytes + let buf = match field.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => { + tracing::error!("Failed to read field bytes: {e}"); + continue; + } + }; + + if buf.is_empty() { + tracing::warn!("Empty field, skipping"); + continue; + } + tracing::info!("Read {} bytes from {}", buf.len(), file_name); + + if file_name.ends_with(".zip") || file_name.ends_with(".ZIP") { + process_zip_upload(&state, instance_id, &buf, &file_name, &mods_dir); + } else if file_name.ends_with(".jar") || file_name.ends_with(".JAR") { + process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir); + } else { + tracing::warn!("Unsupported file type: {file_name}"); + } + processed += 1; + } + Ok(None) => break, + Err(e) => { + tracing::error!("Multer error: {e}"); + break; + } + } + } + + tracing::info!("Upload done, processed {processed} files"); + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +fn get_instance_name(state: &AppState, id: i64) -> Option { + let conn = state.db.conn(); + let result = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![id], + |row| row.get::<_, String>(0), + ) + .ok(); + drop(conn); + result +} + +fn process_jar_upload( + state: &AppState, + instance_id: i64, + bytes: &[u8], + filename: &str, + mods_dir: &std::path::Path, +) { + // Strip directory components — use only the basename + let safe_name = std::path::Path::new(filename) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.to_string()); + + tracing::info!("Validating {safe_name}"); + let result = mods::validate_mod(bytes, &safe_name); + tracing::info!( + "Result for {safe_name}: accepted={}, side={}", + result.accepted, + result.side + ); + + if result.accepted { + let dest = mods_dir.join(&safe_name); + if std::fs::write(&dest, bytes).is_ok() { + tracing::info!("Written to disk: {}", dest.display()); + } else { + tracing::error!("Failed to write {}", dest.display()); + } + } + + let conn = state.db.conn(); + if let Err(e) = conn.execute( + "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + instance_id, + safe_name, + result.display_name, + result.side.to_string(), + result.accepted, + result.rejection_reason, + ], + ) { + tracing::error!("DB insert failed: {e}"); + } + drop(conn); + + if result.accepted { + let conn = state.db.conn(); + let _ = conn.execute( + "UPDATE instances SET has_modpack = 1 WHERE id = ?1", + params![instance_id], + ); + drop(conn); + } +} + +fn process_zip_upload( + state: &AppState, + instance_id: i64, + bytes: &[u8], + filename: &str, + mods_dir: &std::path::Path, +) { + tracing::info!("Extracting ZIP archive {filename}"); + let cursor = Cursor::new(bytes); + let mut archive = match zip::ZipArchive::new(cursor) { + Ok(a) => a, + Err(e) => { + tracing::error!("Failed to read ZIP {filename}: {e}"); + return; + } + }; + + for i in 0..archive.len() { + let mut entry = match archive.by_index(i) { + Ok(e) => e, + Err(_) => continue, + }; + + let entry_name = entry.name().to_string(); + if !entry_name.ends_with(".jar") && !entry_name.ends_with(".JAR") { + continue; + } + + // Use only the basename (strip directory components) + let base_name = std::path::Path::new(&entry_name) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| entry_name.clone()); + + let mut jar_bytes = Vec::new(); + if entry.read_to_end(&mut jar_bytes).is_err() { + continue; + } + + tracing::info!( + "Found JAR inside ZIP: {entry_name} => {base_name} ({} bytes)", + jar_bytes.len() + ); + process_jar_upload(state, instance_id, &jar_bytes, &base_name, mods_dir); + } +} + +async fn rescan_mods( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let instance_name = match get_instance_name(&state, instance_id) { + Some(n) => n, + None => return Err(Redirect::to("/instances")), + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("mods"); + + if let Ok(entries) = std::fs::read_dir(&mods_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if !filename.ends_with(".jar") && !filename.ends_with(".JAR") { + continue; + } + let data = match std::fs::read(&path) { + Ok(d) => d, + Err(_) => continue, + }; + + tracing::info!("Rescanning existing mod: {filename}"); + process_jar_upload(&state, instance_id, &data, &filename, &mods_dir); + } + } + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +async fn delete_mod( + State(state): State, + Path((instance_id, mod_id)): Path<(i64, i64)>, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let (filename, instance_name): (String, String) = conn + .query_row( + "SELECT m.filename, i.name FROM mods m + JOIN instances i ON i.id = m.instance_id + WHERE m.id = ?1 AND m.instance_id = ?2", + params![mod_id, instance_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + + // Delete file from disk + let mod_path = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("mods") + .join(&filename); + let _ = std::fs::remove_file(&mod_path); + + // Remove from DB + conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id]) + .ok(); + + // Update has_modpack flag: clear if no mods remain + let remaining: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 1", + params![instance_id], + |row| row.get(0), + ) + .unwrap_or(0); + if remaining == 0 { + conn.execute( + "UPDATE instances SET has_modpack = 0 WHERE id = ?1", + params![instance_id], + ) + .ok(); + } + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} diff --git a/src/routes/players.rs b/src/routes/players.rs index 5c90efb..7cfb722 100644 --- a/src/routes/players.rs +++ b/src/routes/players.rs @@ -75,14 +75,13 @@ async fn manage_players( } fn read_json_list(path: &std::path::Path) -> Vec { - if let Ok(contents) = std::fs::read_to_string(path) { - if let Ok(entries) = serde_json::from_str::>(&contents) { + if let Ok(contents) = std::fs::read_to_string(path) + && let Ok(entries) = serde_json::from_str::>(&contents) { return entries .iter() .filter_map(|e| e["name"].as_str().map(String::from)) .collect(); } - } vec![] } -- cgit v1.2.3