From 1102f17a93111dca640d790cf9963a9ec9312096 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Jul 2026 11:19:12 +0200 Subject: Enable/disable mods, batch toggle & delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add column to mods table (auto-migrated) - Toggle endpoint: rename .jar ↔ .jar.disabled on disk, flip DB flag - Batch toggle & delete with checkboxes + JS helper (fixes nested-form bug) - Raw Vec<(String,String)> form deserialization fixes 'duplicate field' error - Batch action bars at top and bottom of mod list - Uploads default to enabled=1, rescan skips .jar.disabled files --- src/db.rs | 3 + src/routes/mods.rs | 455 ++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 351 insertions(+), 107 deletions(-) (limited to 'src') diff --git a/src/db.rs b/src/db.rs index 2024032..4d2de88 100644 --- a/src/db.rs +++ b/src/db.rs @@ -114,6 +114,9 @@ impl Database { let _ = conn.execute_batch( "ALTER TABLE instances ADD COLUMN whitelist_enabled INTEGER NOT NULL DEFAULT 1", ); + // Migration: add enabled column to mods for disable/enable toggling + let _ = + conn.execute_batch("ALTER TABLE mods ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1"); Ok(()) } diff --git a/src/routes/mods.rs b/src/routes/mods.rs index d8f0845..1769aca 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs @@ -1,9 +1,9 @@ use askama::Template; use axum::{ - Router, + Form, Router, + body::Body, extract::{Path, State}, http::Request, - body::Body, response::{Html, Redirect}, routing::{get, post}, }; @@ -27,12 +27,14 @@ struct ModListTemplate { flash: Option, } +#[derive(Debug)] struct ModRow { id: i64, filename: String, display_name: Option, side: String, accepted: bool, + enabled: bool, rejection_reason: Option, created_at: String, } @@ -42,10 +44,23 @@ struct FlashMessage { text: String, } +/// Helper: extract all `mod_id` values from raw form key-value pairs. +fn parse_mod_ids(pairs: &[(String, String)]) -> Vec { + pairs + .iter() + .filter(|(k, _)| k == "mod_id") + .filter_map(|(_, v)| v.parse().ok()) + .collect() +} + 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/{mod_id}/toggle", post(toggle_mod)) + .route("/instances/{id}/mods/batch-disable", post(batch_disable)) + .route("/instances/{id}/mods/batch-delete", post(batch_delete)) + .route("/instances/{id}/mods/batch-toggle", post(batch_toggle)) .route("/instances/{id}/mods/rescan", post(rescan_mods)) } @@ -65,7 +80,7 @@ async fn list_mods( let mut stmt = conn .prepare( - "SELECT id, filename, display_name, side, accepted, rejection_reason, created_at + "SELECT id, filename, display_name, side, accepted, enabled, rejection_reason, created_at FROM mods WHERE instance_id = ?1 ORDER BY created_at DESC", ) .map_err(|_| Redirect::to("/instances"))?; @@ -78,8 +93,9 @@ async fn list_mods( 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)?, + enabled: row.get::<_, i32>(5)? != 0, + rejection_reason: row.get(6)?, + created_at: row.get(7)?, }) }) .map_err(|_| Redirect::to("/instances"))? @@ -107,6 +123,8 @@ async fn list_mods( } } +// ── Single-mod actions ─────────────────────────────────────────────── + async fn upload_mod( State(state): State, Path(instance_id): Path, @@ -120,7 +138,6 @@ async fn upload_mod( None => return Err(Redirect::to("/instances")), }; - // Sanitize instance name to prevent path traversal let safe_name = match sanitize::sanitize_instance_name(&instance_name) { Some(n) => n, None => return Err(Redirect::to("/instances")), @@ -135,7 +152,7 @@ async fn upload_mod( .join("mods"); let _ = std::fs::create_dir_all(&mods_dir); - // Build multer multipart parser with generous size limit (500MB) + // Build multer multipart parser let (parts, body) = req.into_parts(); let content_type = parts .headers @@ -143,8 +160,6 @@ async fn upload_mod( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - tracing::info!("Content-Type: {content_type}"); - let boundary = content_type .split(';') .find_map(|part| { @@ -173,14 +188,6 @@ async fn upload_mod( .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) => { @@ -188,19 +195,15 @@ async fn upload_mod( 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") { + let lower = file_name.to_lowercase(); + if lower.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") { + } else if lower.ends_with(".jar") { process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir); - } else { - tracing::warn!("Unsupported file type: {file_name}"); } processed += 1; } @@ -216,6 +219,320 @@ async fn upload_mod( Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) } +/// Toggle a mod between enabled and disabled. +/// Disabled = `.jar` renamed to `.jar.disabled` on disk so the server skips it. +async fn toggle_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, currently_enabled): (String, String, bool) = conn + .query_row( + "SELECT m.filename, i.name, m.enabled 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)?, row.get::<_, i32>(2)? != 0)), + ) + .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + drop(conn); + + let safe_name = sanitize::sanitize_instance_name(&instance_name) + .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + let safe_filename = sanitize::sanitize_backup_filename(&filename) + .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&safe_name) + .join("data") + .join("mods"); + + let base_path = mods_dir.join(&safe_filename); + + if currently_enabled { + // Disable: rename .jar → .jar.disabled + let disabled_path = mods_dir.join(format!("{safe_filename}.disabled")); + if base_path.exists() { + let _ = std::fs::rename(&base_path, &disabled_path); + } + } else { + // Enable: rename .jar.disabled → .jar + let disabled_path = mods_dir.join(format!("{safe_filename}.disabled")); + if disabled_path.exists() { + let _ = std::fs::rename(&disabled_path, &base_path); + } else if !base_path.exists() { + // Neither exists — file may have been deleted out of band; just toggle the DB + tracing::warn!("Mod file not found on disk: {safe_filename}"); + } + } + + let conn = state.db.conn(); + let _ = conn.execute( + "UPDATE mods SET enabled = NOT enabled WHERE id = ?1", + params![mod_id], + ); + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +/// Permanently delete a single mod (file + DB record). +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")))?; + + let safe_name = sanitize::sanitize_instance_name(&instance_name) + .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + let safe_filename = sanitize::sanitize_backup_filename(&filename) + .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&safe_name) + .join("data") + .join("mods"); + + // Try deleting both the .jar and the .jar.disabled variant + let jar_path = mods_dir.join(&safe_filename); + let disabled_path = mods_dir.join(format!("{safe_filename}.disabled")); + let _ = std::fs::remove_file(&jar_path); + let _ = std::fs::remove_file(&disabled_path); + + // Remove from DB + conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id]) + .ok(); + + 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"))) +} + +// ── Batch actions ───────────────────────────────────────────────────── + +/// Disable all checked mods at once. +async fn batch_disable( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, + Form(form): Form>, +) -> Result { + let mod_ids = parse_mod_ids(&form); + for mod_id in &mod_ids { + let conn = state.db.conn(); + let (filename, instance_name): (String, String) = match 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)?)), + ) { + Ok(v) => v, + Err(_) => continue, + }; + drop(conn); + + let safe_name = match sanitize::sanitize_instance_name(&instance_name) { + Some(n) => n, + None => continue, + }; + let safe_filename = match sanitize::sanitize_backup_filename(&filename) { + Some(f) => f, + None => continue, + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&safe_name) + .join("data") + .join("mods"); + + let jar = mods_dir.join(&safe_filename); + let disabled = mods_dir.join(format!("{safe_filename}.disabled")); + if jar.exists() { + let _ = std::fs::rename(&jar, &disabled); + } + + let conn = state.db.conn(); + let _ = conn.execute( + "UPDATE mods SET enabled = 0 WHERE id = ?1", + params![mod_id], + ); + drop(conn); + } + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +/// Permanently delete all checked mods at once. +async fn batch_delete( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, + Form(form): Form>, +) -> Result { + let mod_ids = parse_mod_ids(&form); + for mod_id in &mod_ids { + let conn = state.db.conn(); + let (filename, instance_name): (String, String) = match 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)?)), + ) { + Ok(v) => v, + Err(_) => continue, + }; + drop(conn); + + let safe_name = match sanitize::sanitize_instance_name(&instance_name) { + Some(n) => n, + None => continue, + }; + let safe_filename = match sanitize::sanitize_backup_filename(&filename) { + Some(f) => f, + None => continue, + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&safe_name) + .join("data") + .join("mods"); + + let _ = std::fs::remove_file(mods_dir.join(&safe_filename)); + let _ = std::fs::remove_file(mods_dir.join(format!("{safe_filename}.disabled"))); + + let conn = state.db.conn(); + conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id]) + .ok(); + drop(conn); + } + + // Update has_modpack flag + let conn = state.db.conn(); + 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 { + let _ = conn.execute( + "UPDATE instances SET has_modpack = 0 WHERE id = ?1", + params![instance_id], + ); + } + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +/// Toggle (enable/disable) all checked mods at once — each flips its state. +async fn batch_toggle( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, + Form(form): Form>, +) -> Result { + let mod_ids = parse_mod_ids(&form); + for mod_id in &mod_ids { + let conn = state.db.conn(); + let (filename, instance_name, currently_enabled): (String, String, bool) = + match conn.query_row( + "SELECT m.filename, i.name, m.enabled 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)?, row.get::<_, i32>(2)? != 0)), + ) { + Ok(v) => v, + Err(_) => continue, + }; + drop(conn); + + let safe_name = match sanitize::sanitize_instance_name(&instance_name) { + Some(n) => n, + None => continue, + }; + let safe_filename = match sanitize::sanitize_backup_filename(&filename) { + Some(f) => f, + None => continue, + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&safe_name) + .join("data") + .join("mods"); + + let jar = mods_dir.join(&safe_filename); + let disabled = mods_dir.join(format!("{safe_filename}.disabled")); + + if currently_enabled { + // Was enabled → now disable + if jar.exists() { + let _ = std::fs::rename(&jar, &disabled); + } + } else { + // Was disabled → now enable + if disabled.exists() { + let _ = std::fs::rename(&disabled, &jar); + } + } + + let conn = state.db.conn(); + let _ = conn.execute( + "UPDATE mods SET enabled = NOT enabled WHERE id = ?1", + params![mod_id], + ); + drop(conn); + } + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +// ── Helpers ─────────────────────────────────────────────────────────── + fn get_instance_name(state: &AppState, id: i64) -> Option { let conn = state.db.conn(); let result = conn @@ -236,7 +553,6 @@ fn process_jar_upload( 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()) @@ -244,18 +560,11 @@ fn process_jar_upload( 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 - ); - // FIX: Insert DB record BEFORE writing file to disk. - // This prevents orphaned mod files if the process crashes between write and DB insert. 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)", + "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason, enabled) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)", params![ instance_id, safe_name, @@ -271,7 +580,6 @@ fn process_jar_upload( } drop(conn); - // Now write to disk (DB insert succeeded, so on restart we can detect incomplete writes) if result.accepted { let dest = mods_dir.join(&safe_name); if std::fs::write(&dest, bytes).is_ok() { @@ -306,18 +614,18 @@ fn process_zip_upload( } }; - for i in 0..archive.len() { + for i in 0..archive.len().min(500) { 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") { + let lower = entry_name.to_lowercase(); + if !lower.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()) @@ -328,10 +636,6 @@ fn process_zip_upload( 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); } } @@ -346,7 +650,6 @@ async fn rescan_mods( None => return Err(Redirect::to("/instances")), }; - // Sanitize instance name to prevent path traversal let safe_name = match sanitize::sanitize_instance_name(&instance_name) { Some(n) => n, None => return Err(Redirect::to("/instances")), @@ -370,7 +673,8 @@ async fn rescan_mods( .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); - if !filename.ends_with(".jar") && !filename.ends_with(".JAR") { + // Skip disabled mods (intentionally deactivated) and non-jar files + if !filename.ends_with(".jar") || filename.ends_with(".jar.disabled") { continue; } let data = match std::fs::read(&path) { @@ -385,66 +689,3 @@ async fn rescan_mods( 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")))?; - - // Sanitize instance name to prevent path traversal - let safe_name = match sanitize::sanitize_instance_name(&instance_name) { - Some(n) => n, - None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), - }; - - // Sanitize filename to prevent path traversal - let safe_filename = match sanitize::sanitize_backup_filename(&filename) { - Some(f) => f, - None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))), - }; - - // Delete file from disk - let mod_path = state - .config - .server_directory - .join("instances") - .join(&safe_name) - .join("data") - .join("mods") - .join(&safe_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"))) -} -- cgit v1.2.3