use askama::Template; use axum::{ Form, Router, body::Body, extract::{Path, State}, http::Request, 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; use crate::sanitize; #[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, } #[derive(Debug)] struct ModRow { id: i64, filename: String, display_name: Option, side: String, accepted: bool, enabled: bool, rejection_reason: Option, created_at: String, } struct FlashMessage { kind: String, 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)) } 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, enabled, 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, enabled: row.get::<_, i32>(5)? != 0, rejection_reason: row.get(6)?, created_at: row.get(7)?, }) }) .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")) } } } // ── Single-mod actions ─────────────────────────────────────────────── 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 safe_name = match sanitize::sanitize_instance_name(&instance_name) { Some(n) => n, None => return Err(Redirect::to("/instances")), }; let mods_dir = state .config .server_directory .join("instances") .join(&safe_name) .join("data") .join("mods"); let _ = std::fs::create_dir_all(&mods_dir); // Build multer multipart parser let (parts, body) = req.into_parts(); let content_type = parts .headers .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); 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()); 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() { continue; } let lower = file_name.to_lowercase(); if lower.ends_with(".zip") { process_zip_upload(&state, instance_id, &buf, &file_name, &mods_dir); } else if lower.ends_with(".jar") { process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir); } 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"))) } /// 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 .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, ) { 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); let conn = state.db.conn(); if let Err(e) = conn.execute( "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, result.display_name, result.side.to_string(), result.accepted, result.rejection_reason, ], ) { tracing::error!("DB insert failed: {e}"); drop(conn); return; } drop(conn); 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(); 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().min(500) { let mut entry = match archive.by_index(i) { Ok(e) => e, Err(_) => continue, }; let entry_name = entry.name().to_string(); let lower = entry_name.to_lowercase(); if !lower.ends_with(".jar") { continue; } 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; } 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 safe_name = match sanitize::sanitize_instance_name(&instance_name) { Some(n) => n, None => return Err(Redirect::to("/instances")), }; let mods_dir = state .config .server_directory .join("instances") .join(&safe_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(); // 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) { 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"))) }