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"))) }