use askama::Template; use axum::{ Form, Router, extract::{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, has_active: bool, } #[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, max_memory_mb: u32, error: Option, } 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)) } 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(); let has_active = instances.iter().any(|i| i.is_active); drop(stmt); drop(conn); let template = InstanceListTemplate { username: account.username.clone(), is_superuser: account.is_superuser, password_change_required: account.password_change_required, instances, has_active, }; 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 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, form.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")) } async fn edit_form( State(state): State, Path(id): Path, CurrentUser(account): CurrentUser, ) -> Result, axum::response::Redirect> { let conn = state.db.conn(); let 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 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"))?; drop(conn); let template = EditInstanceTemplate { username: account.username.clone(), is_superuser: account.is_superuser, password_change_required: account.password_change_required, instance, max_memory_mb: state.config.max_memory_mb, error: None, }; Ok(Html(template.render().unwrap_or_default())) } #[axum::debug_handler] 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(); 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 = 'running' 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("/")) } 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("/")) } 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) }