From ae7ef10b67737f08a86743f687bca30a12e9e17a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 19:38:03 +0200 Subject: first stable --- src/routes/instances.rs | 484 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 484 insertions(+) create mode 100644 src/routes/instances.rs (limited to 'src/routes/instances.rs') diff --git a/src/routes/instances.rs b/src/routes/instances.rs new file mode 100644 index 0000000..2da1319 --- /dev/null +++ b/src/routes/instances.rs @@ -0,0 +1,484 @@ +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) +} -- cgit v1.2.3