use askama::Template; use axum::{ Form, Router, extract::{Multipart, Path, State}, response::{Html, Redirect}, routing::{get, post}, }; use rusqlite::params; use serde::Deserialize; use crate::auth::CurrentUser; use crate::models::Instance; use crate::routes::AppState; #[derive(Template)] #[template(path = "instances/list.html")] struct InstanceListTemplate { username: String, is_superuser: bool, password_change_required: bool, instances: Vec, } #[derive(Template)] #[template(path = "instances/create.html")] struct CreateInstanceTemplate { username: String, is_superuser: bool, password_change_required: bool, versions: Vec, max_memory_mb: u32, error: Option, } #[derive(Template)] #[template(path = "instances/edit.html")] struct EditInstanceTemplate { username: String, is_superuser: bool, password_change_required: bool, instance: Instance, current_seed: Option, max_memory_mb: u32, } struct InstanceInfo { id: i64, name: String, version: String, server_type: String, is_active: bool, has_modpack: bool, } #[derive(Debug, Deserialize)] pub struct CreateInstanceForm { pub name: String, pub version: String, pub server_type: String, pub world_source: String, pub seed: Option, pub level_type: Option, pub memory_mb: i32, pub difficulty: i32, pub gamemode: i32, pub max_players: i32, pub pvp: Option, pub motd: Option, } #[derive(Debug, Deserialize)] pub struct EditInstanceForm { pub difficulty: i32, pub gamemode: i32, pub max_players: i32, pub pvp: Option, pub motd: Option, pub memory_mb: i32, } pub fn router() -> Router { Router::new() .route("/instances", get(list_instances)) .route("/instances/new", get(create_form)) .route("/instances/new", post(create_instance)) .route("/instances/{id}/edit", get(edit_form)) .route("/instances/{id}/edit", post(edit_instance)) .route("/instances/{id}/delete", post(delete_instance)) .route("/instances/{id}/activate", post(activate_instance)) .route("/instances/deactivate", post(deactivate_instance)) .route("/instances/{id}/world/export", get(export_world)) .route("/instances/{id}/world/import", post(import_world)) .route("/instances/{id}/world/regenerate", post(regenerate_world)) } async fn list_instances( State(state): State, CurrentUser(account): CurrentUser, ) -> Result, axum::response::Redirect> { let conn = state.db.conn(); let mut stmt = conn .prepare("SELECT id, name, version, server_type, is_active, has_modpack FROM instances ORDER BY name") .map_err(|_| Redirect::to("/login"))?; let instances: Vec = stmt .query_map([], |row| { Ok(InstanceInfo { id: row.get(0)?, name: row.get(1)?, version: row.get(2)?, server_type: row.get(3)?, is_active: row.get::<_, i32>(4)? != 0, has_modpack: row.get::<_, i32>(5)? != 0, }) }) .map_err(|_| Redirect::to("/login"))? .filter_map(|r| r.ok()) .collect(); drop(stmt); drop(conn); let template = InstanceListTemplate { username: account.username.clone(), is_superuser: account.is_superuser, password_change_required: account.password_change_required, instances, }; Ok(Html(template.render().unwrap_or_default())) } async fn create_form( State(state): State, CurrentUser(account): CurrentUser, ) -> Result, axum::response::Redirect> { let versions = fetch_minecraft_versions().await.unwrap_or_else(|_| { vec![ "1.21".into(), "1.20.6".into(), "1.20.1".into(), "1.19.4".into(), "1.12.2".into(), ] }); let template = CreateInstanceTemplate { username: account.username.clone(), is_superuser: account.is_superuser, password_change_required: account.password_change_required, versions, max_memory_mb: state.config.max_memory_mb, error: None, }; Ok(Html(template.render().unwrap_or_default())) } async fn create_instance( State(state): State, CurrentUser(_user): CurrentUser, Form(form): Form, ) -> Result { if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 { return Ok(Redirect::to("/instances/new")); } let rcon_password = generate_rcon_password(); let pvp = form.pvp.as_deref() == Some("on"); let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into()); let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into()); let seed = form.seed.filter(|s| !s.is_empty()); let conn = state.db.conn(); conn.execute( "INSERT INTO instances (name, version, server_type, world_source, world_seed, level_type, memory_mb, difficulty, gamemode, max_players, pvp, motd, rcon_password, has_modpack) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)", params![ form.name, form.version, form.server_type, form.world_source, seed, level_type, form.memory_mb, form.difficulty, form.gamemode, form.max_players, pvp, motd, rcon_password, ], ) .ok(); drop(conn); let instance_dir = state .config .server_directory .join("instances") .join(&form.name) .join("data"); let _ = std::fs::create_dir_all(&instance_dir); Ok(Redirect::to("/instances")) } #[axum::debug_handler] async fn edit_form( State(state): State, Path(id): Path, CurrentUser(account): CurrentUser, ) -> Result, axum::response::Redirect> { let instance = { 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 FROM instances WHERE id = ?1", params![id], |row| { Ok(Instance { id: row.get(0)?, name: row.get(1)?, version: row.get(2)?, server_type: row.get(3)?, world_source: row.get(4)?, world_seed: row.get(5)?, level_type: row.get(6)?, has_modpack: row.get::<_, i32>(7)? != 0, memory_mb: row.get(8)?, difficulty: row.get(9)?, 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)?, }) }, ) .map_err(|_| Redirect::to("/instances"))? }; let db_seed = instance.world_seed.clone().filter(|s| !s.is_empty()); let current_seed = crate::properties::read_instance_seed(&state.config.server_directory, &instance.name) .or(db_seed); // If no seed known yet, try RCON /seed if the server is running let current_seed = if current_seed.is_none() && instance.is_active { let rcon = crate::rcon::RconClient::new(instance.rcon_password.clone()); match rcon.send_command_async("seed".into()).await { Ok(resp) => { let s = resp.trim(); // Format: "Seed: [1234567890]" s.strip_prefix("Seed: [") .and_then(|r| r.strip_suffix(']')) .or_else(|| s.strip_prefix("Seed: ")) .map(|v| v.trim().to_string()) } Err(e) => { tracing::debug!("RCON /seed failed: {e}"); None } } } else { current_seed }; let template = EditInstanceTemplate { username: account.username.clone(), is_superuser: account.is_superuser, password_change_required: account.password_change_required, instance, current_seed, max_memory_mb: state.config.max_memory_mb, }; Ok(Html(template.render().unwrap_or_default())) } async fn edit_instance( State(state): State, Path(id): Path, CurrentUser(_user): CurrentUser, Form(form): Form, ) -> Result { if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 { return Ok(Redirect::to(&format!("/instances/{id}/edit"))); } let pvp = form.pvp.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", params![ form.difficulty, form.gamemode, form.max_players, pvp, form.motd, form.memory_mb, id, ], ) .map_err(|_| Redirect::to("/instances"))?; conn.query_row( "SELECT is_active FROM instances WHERE id = ?1", params![id], |row| row.get::<_, i32>(0).map(|v| v != 0), ) .unwrap_or(false) }; if is_active { let _ = state.docker.stop_container().await; } Ok(Redirect::to("/instances")) } async fn delete_instance( State(state): State, Path(id): Path, CurrentUser(_user): CurrentUser, ) -> Result { let conn = state.db.conn(); let is_active: bool = conn .query_row( "SELECT is_active FROM instances WHERE id = ?1", params![id], |row| row.get::<_, i32>(0).map(|v| v != 0), ) .unwrap_or(false); if is_active { drop(conn); return Ok(Redirect::to("/instances")); } let name: Option = conn .query_row( "SELECT name FROM instances WHERE id = ?1", params![id], |row| row.get(0), ) .ok(); conn.execute("DELETE FROM instances WHERE id = ?1", params![id]) .ok(); drop(conn); if let Some(name) = name { let instance_dir = state.config.server_directory.join("instances").join(&name); let _ = std::fs::remove_dir_all(&instance_dir); } Ok(Redirect::to("/instances")) } #[axum::debug_handler] async fn activate_instance( State(state): State, Path(id): Path, CurrentUser(_user): CurrentUser, ) -> Result { // Stop any running container first (this is usually fast) let _ = state.docker.stop_container().await; // Update DB: deactivate all, activate the new one let instance = { let conn = state.db.conn(); conn.execute("UPDATE instances SET is_active = 0 WHERE is_active = 1", []) .ok(); conn.execute( "UPDATE instances SET is_active = 1, updated_at = datetime('now') WHERE id = ?1", params![id], ) .ok(); 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 FROM instances WHERE id = ?1", params![id], |row| { Ok(Instance { id: row.get(0)?, name: row.get(1)?, version: row.get(2)?, server_type: row.get(3)?, world_source: row.get(4)?, world_seed: row.get(5)?, level_type: row.get(6)?, has_modpack: row.get::<_, i32>(7)? != 0, memory_mb: row.get(8)?, difficulty: row.get(9)?, 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)?, }) }, ) .map_err(|_| Redirect::to("/instances"))? }; // Pull image + start container in background so the UI stays responsive let docker = state.docker.clone(); let db = state.db.clone(); let rcon_pw = instance.rcon_password.clone(); tokio::spawn(async move { { let conn = db.conn(); conn.execute( "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1", [], ) .ok(); } match docker.start_container(&instance).await { Ok(()) => { { let conn = db.conn(); conn.execute( "UPDATE instances SET container_state = 'booting' WHERE is_active = 1", [], ) .ok(); } // Wait for Minecraft to be ready via RCON let rcon = crate::rcon::RconClient::new(rcon_pw); for _ in 0..30 { tokio::time::sleep(std::time::Duration::from_secs(2)).await; if rcon.send_command_async("list".into()).await.is_ok() { let conn = db.conn(); conn.execute( "UPDATE instances SET container_state = 'running' WHERE is_active = 1", [], ) .ok(); tracing::info!("Server is ready (RCON responded)"); return; } } tracing::error!("Server did not become ready within 60 seconds"); let conn = db.conn(); conn.execute( "UPDATE instances SET container_state = 'error' WHERE is_active = 1", [], ) .ok(); } Err(e) => { tracing::error!("Failed to start container: {e}"); let conn = db.conn(); conn.execute( "UPDATE instances SET container_state = 'error' WHERE is_active = 1", [], ) .ok(); } } }); Ok(Redirect::to("/instances")) } async fn deactivate_instance( State(state): State, CurrentUser(_user): CurrentUser, ) -> Result { let _ = state.docker.stop_container().await; let conn = state.db.conn(); conn.execute( "UPDATE instances SET is_active = 0, container_state = 'stopped' WHERE is_active = 1", [], ) .ok(); drop(conn); Ok(Redirect::to("/instances")) } fn generate_rcon_password() -> String { use rand::Rng; let mut rng = rand::thread_rng(); let chars: Vec = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect(); (0..16) .map(|_| chars[rng.gen_range(0..chars.len())]) .collect() } async fn fetch_minecraft_versions() -> Result, String> { let client = reqwest::Client::new(); let manifest: serde_json::Value = client .get("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json") .send() .await .map_err(|e| e.to_string())? .json() .await .map_err(|e| e.to_string())?; let versions: Vec = manifest["versions"] .as_array() .ok_or("Invalid version manifest")? .iter() .filter_map(|v| { let t = v["type"].as_str()?; if t == "release" { v["id"].as_str().map(String::from) } else { None } }) .collect(); Ok(versions) } // ── World management ────────────────────────────────────────────── #[derive(Debug, Deserialize)] pub struct RegenerateWorldForm { pub seed: Option, } async fn export_world( State(state): State, Path(id): Path, CurrentUser(_user): CurrentUser, ) -> Result { let conn = state.db.conn(); let instance_name: String = conn .query_row( "SELECT name FROM instances WHERE id = ?1", params![id], |row| row.get(0), ) .map_err(|_| Redirect::to("/instances"))?; drop(conn); let world_dir = state .config .server_directory .join("instances") .join(&instance_name) .join("data") .join("world"); let mut zip_buf = Vec::new(); { let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); let options: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated); add_dir_to_zip(&mut zip, &world_dir, "", options)?; zip.finish().ok(); } let filename = format!("{instance_name}_world.zip"); Ok(axum::response::Response::builder() .header(axum::http::header::CONTENT_TYPE, "application/zip") .header( axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""), ) .body(axum::body::Body::from(zip_buf)) .unwrap()) } fn add_dir_to_zip( zip: &mut zip::ZipWriter>>, dir: &std::path::Path, prefix: &str, options: zip::write::FileOptions<'_, ()>, ) -> Result<(), axum::response::Redirect> { if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); let name = path.file_name().unwrap().to_string_lossy(); let zip_path = if prefix.is_empty() { name.to_string() } else { format!("{prefix}/{name}") }; if path.is_dir() { zip.add_directory(&zip_path, options).ok(); add_dir_to_zip(zip, &path, &zip_path, options)?; } else if let Ok(contents) = std::fs::read(&path) { zip.start_file(&zip_path, options).ok(); std::io::Write::write_all(zip, &contents).ok(); } } } Ok(()) } #[axum::debug_handler] async fn import_world( State(state): State, Path(id): Path, CurrentUser(_user): CurrentUser, mut multipart: Multipart, ) -> Result { let (instance_name, is_active) = { let conn = state.db.conn(); let row = conn.query_row( "SELECT name, is_active FROM instances WHERE id = ?1", params![id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0)), ); match row { Ok(v) => v, Err(_) => return Ok(Redirect::to("/instances")), } }; if is_active { return Ok(Redirect::to(&format!("/instances/{id}/edit"))); } let data_dir = state .config .server_directory .join("instances") .join(&instance_name) .join("data"); // Extract uploaded ZIP while let Ok(Some(field)) = multipart.next_field().await { let bytes = field .bytes() .await .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; let cursor = std::io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor) .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; // Remove existing world let world_dir = data_dir.join("world"); let _ = std::fs::remove_dir_all(&world_dir); let _ = std::fs::create_dir_all(&world_dir); for i in 0..archive.len() { let mut file = archive .by_index(i) .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; let out_path = match file.enclosed_name() { Some(p) => world_dir.join(p), None => continue, }; if file.is_dir() { let _ = std::fs::create_dir_all(&out_path); } else { if let Some(parent) = out_path.parent() { let _ = std::fs::create_dir_all(parent); } let mut out = std::fs::File::create(&out_path) .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; std::io::copy(&mut file, &mut out).ok(); } } break; // only process first file } Ok(Redirect::to(&format!("/instances/{id}/edit"))) } async fn regenerate_world( State(state): State, Path(id): Path, CurrentUser(_user): CurrentUser, Form(form): Form, ) -> Result { let conn = state.db.conn(); let (instance_name, is_active): (String, bool) = conn .query_row( "SELECT name, is_active FROM instances WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get::<_, i32>(1)? != 0)), ) .map_err(|_| Redirect::to("/instances"))?; drop(conn); if is_active { return Ok(Redirect::to(&format!("/instances/{id}/edit"))); } let props_path = state .config .server_directory .join("instances") .join(&instance_name) .join("data") .join("server.properties"); // Update level-seed in server.properties let seed = form.seed.unwrap_or_default(); let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); let mut new_lines = Vec::new(); let mut found = false; for line in contents.lines() { if line.trim_start().starts_with("level-seed") { new_lines.push(format!("level-seed={seed}")); found = true; } else { new_lines.push(line.to_string()); } } if !found { new_lines.push(format!("level-seed={seed}")); } let _ = std::fs::write(&props_path, new_lines.join("\n")); // Update DB record let db_seed: Option<&str> = if seed.is_empty() { None } else { Some(&seed) }; let conn = state.db.conn(); conn.execute( "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", params![db_seed, id], ) .ok(); drop(conn); // Delete existing world so it gets regenerated on next start let world_dir = state .config .server_directory .join("instances") .join(&instance_name) .join("data") .join("world"); let _ = std::fs::remove_dir_all(&world_dir); Ok(Redirect::to(&format!("/instances/{id}/edit"))) }