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/accounts.rs | 236 +++++++++++++++++++++++ src/routes/backups.rs | 256 +++++++++++++++++++++++++ src/routes/dashboard.rs | 68 +++++++ src/routes/instances.rs | 484 ++++++++++++++++++++++++++++++++++++++++++++++++ src/routes/mod.rs | 75 ++++++++ src/routes/players.rs | 131 +++++++++++++ 6 files changed, 1250 insertions(+) create mode 100644 src/routes/accounts.rs create mode 100644 src/routes/backups.rs create mode 100644 src/routes/dashboard.rs create mode 100644 src/routes/instances.rs create mode 100644 src/routes/mod.rs create mode 100644 src/routes/players.rs (limited to 'src/routes') diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs new file mode 100644 index 0000000..89774f8 --- /dev/null +++ b/src/routes/accounts.rs @@ -0,0 +1,236 @@ +use askama::Template; +use axum::{ + Form, Router, + extract::State, + response::{Html, Redirect}, + routing::{get, post}, +}; +use rusqlite::params; +use serde::Deserialize; + +use crate::auth::{CurrentUser, hash_password}; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "accounts/manage.html")] +struct AccountManagementTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + accounts: Vec, + message: Option, +} + +struct AccountRow { + id: i64, + username: String, + is_superuser: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateAccountForm { + pub username: String, + pub password: String, + pub is_superuser: Option, +} + +#[derive(Debug, Deserialize)] +pub struct DeleteAccountForm { + pub id: i64, +} + +#[derive(Debug, Deserialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub new_password: String, +} + +#[derive(Debug, Deserialize)] +pub struct SetPasswordForm { + pub id: i64, + pub new_password: String, +} + +pub fn router() -> Router { + Router::new() + .route("/accounts", get(manage_accounts)) + .route("/accounts/create", post(create_account)) + .route("/accounts/delete", post(delete_account)) + .route("/accounts/set-password", post(set_password)) + .route( + "/accounts/change-own-password", + post(change_own_password_from_accounts), + ) +} + +pub async fn change_password( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + let conn = state.db.conn(); + + let hash: String = conn + .query_row( + "SELECT password FROM accounts WHERE id = ?1", + params![account.id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/login"))?; + + if !crate::auth::verify_password(&form.current_password, &hash).unwrap_or(false) { + drop(conn); + return Ok(Redirect::to("/change-password")); + } + + let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/login"))?; + + conn.execute( + "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2", + params![new_hash, account.id], + ) + .map_err(|_| Redirect::to("/login"))?; + + conn.execute( + "DELETE FROM sessions WHERE account_id = ?1", + params![account.id], + ) + .ok(); + drop(conn); + + Ok(Redirect::to("/login")) +} + +async fn manage_accounts( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result, Redirect> { + if !account.is_superuser { + return Err(Redirect::to("/")); + } + + let conn = state.db.conn(); + let mut stmt = conn + .prepare("SELECT id, username, is_superuser FROM accounts ORDER BY username") + .map_err(|_| Redirect::to("/login"))?; + + let accounts: Vec = stmt + .query_map([], |row| { + Ok(AccountRow { + id: row.get(0)?, + username: row.get(1)?, + is_superuser: row.get::<_, i32>(2)? != 0, + }) + }) + .map_err(|_| Redirect::to("/login"))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + drop(conn); + + let template = AccountManagementTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + accounts, + message: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +async fn create_account( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + if !account.is_superuser { + return Ok(Redirect::to("/")); + } + + let hash = hash_password(&form.password).map_err(|_| Redirect::to("/accounts"))?; + let is_superuser = form.is_superuser.as_deref() == Some("on"); + + let conn = state.db.conn(); + let _ = conn.execute( + "INSERT INTO accounts (username, password, is_superuser) VALUES (?1, ?2, ?3)", + params![form.username, hash, is_superuser], + ); + drop(conn); + + Ok(Redirect::to("/accounts")) +} + +async fn delete_account( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + if !account.is_superuser || account.id == form.id { + return Ok(Redirect::to("/accounts")); + } + + let conn = state.db.conn(); + let superuser_count: i32 = conn + .query_row( + "SELECT COUNT(*) FROM accounts WHERE is_superuser = 1 AND id != ?1", + params![form.id], + |row| row.get(0), + ) + .unwrap_or(0); + + let is_superuser: bool = conn + .query_row( + "SELECT is_superuser FROM accounts WHERE id = ?1", + params![form.id], + |row| row.get::<_, i32>(0).map(|v| v != 0), + ) + .unwrap_or(false); + + if is_superuser && superuser_count == 0 { + drop(conn); + return Ok(Redirect::to("/accounts")); + } + + let _ = conn.execute("DELETE FROM accounts WHERE id = ?1", params![form.id]); + drop(conn); + + Ok(Redirect::to("/accounts")) +} + +async fn set_password( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + if !account.is_superuser { + return Ok(Redirect::to("/")); + } + + let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/accounts"))?; + + let conn = state.db.conn(); + conn.execute( + "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2", + params![new_hash, form.id], + ) + .ok(); + + // Invalidate sessions for the target account + conn.execute( + "DELETE FROM sessions WHERE account_id = ?1", + params![form.id], + ) + .ok(); + drop(conn); + + Ok(Redirect::to("/accounts")) +} + +async fn change_own_password_from_accounts( + State(state): State, + CurrentUser(account): CurrentUser, + Form(form): Form, +) -> Result { + change_password(State(state), CurrentUser(account), Form(form)).await +} diff --git a/src/routes/backups.rs b/src/routes/backups.rs new file mode 100644 index 0000000..09298d2 --- /dev/null +++ b/src/routes/backups.rs @@ -0,0 +1,256 @@ +use askama::Template; +use axum::{ + Router, + body::Body, + extract::{Path, State}, + http::{StatusCode, header}, + response::{Html, Redirect, Response}, + routing::{get, post}, +}; +use rusqlite::params; +use zip::write::FileOptions; + +use crate::auth::CurrentUser; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "backups/list.html")] +struct BackupListTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instance_name: String, + instance_id: i64, + backups: Vec, + message: Option, +} + +struct BackupRow { + id: i64, + filename: String, + created_at: String, +} + +pub fn router() -> Router { + Router::new() + .route("/instances/{id}/backups", get(list_backups)) + .route("/instances/{id}/backups/create", post(create_backup)) + .route("/backups/{id}/restore", post(restore_backup)) + .route("/backups/{id}/download", get(download_backup)) + .route("/instances/{id}/world/export", get(export_world)) +} + +async fn list_backups( + 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, created_at FROM backups WHERE instance_id = ?1 ORDER BY created_at DESC") + .map_err(|_| Redirect::to("/instances"))?; + + let backups: Vec = stmt + .query_map(params![instance_id], |row| { + Ok(BackupRow { + id: row.get(0)?, + filename: row.get(1)?, + created_at: row.get(2)?, + }) + }) + .map_err(|_| Redirect::to("/instances"))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + drop(conn); + + let template = BackupListTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instance_name, + instance_id, + backups, + message: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +async fn create_backup( + State(state): State, + Path(instance_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![instance_id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/instances"))?; + drop(conn); + + let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S"); + let filename = format!("{instance_name}_{timestamp}.zip"); + + let data_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data"); + + let backup_dir = state.config.server_directory.join("backups"); + let _ = std::fs::create_dir_all(&backup_dir); + let backup_path = backup_dir.join(&filename); + + if let Ok(file) = std::fs::File::create(&backup_path) { + let mut zip = zip::ZipWriter::new(file); + let options: FileOptions<'_, ()> = + FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + if let Ok(entries) = std::fs::read_dir(&data_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name() { + if let Ok(contents) = std::fs::read(&path) { + let _ = zip.start_file(&*name.to_string_lossy(), options); + let _ = std::io::Write::write_all(&mut zip, &contents); + } + } + } + } + } + let _ = zip.finish(); + } + + let conn = state.db.conn(); + conn.execute( + "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)", + params![instance_id, filename], + ) + .ok(); + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) +} + +async fn restore_backup( + State(state): State, + Path(backup_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let (instance_id, _filename): (i64, String) = conn + .query_row( + "SELECT instance_id, filename FROM backups WHERE id = ?1", + params![backup_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| Redirect::to("/instances"))?; + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/backups"))) +} + +async fn download_backup( + State(state): State, + Path(backup_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let (_, filename): (i64, String) = conn + .query_row( + "SELECT instance_id, filename FROM backups WHERE id = ?1", + params![backup_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?; + drop(conn); + + let backup_path = state + .config + .server_directory + .join("backups") + .join(&filename); + + let content = + std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?; + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "application/zip") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + ) + .body(Body::from(content)) + .unwrap()) +} + +async fn export_world( + State(state): State, + Path(instance_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![instance_id], + |row| row.get(0), + ) + .map_err(|_| (StatusCode::NOT_FOUND, "Instance not found"))?; + 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: FileOptions<'_, ()> = + FileOptions::default().compression_method(zip::CompressionMethod::Deflated); + + if let Ok(entries) = std::fs::read_dir(&world_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(name) = path.file_name() { + if let Ok(contents) = std::fs::read(&path) { + let _ = zip.start_file(&*name.to_string_lossy(), options); + let _ = std::io::Write::write_all(&mut zip, &contents); + } + } + } + } + } + let _ = zip.finish(); + } + + let filename = format!("{instance_name}_world.zip"); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "application/zip") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{filename}\""), + ) + .body(Body::from(zip_buf)) + .unwrap()) +} diff --git a/src/routes/dashboard.rs b/src/routes/dashboard.rs new file mode 100644 index 0000000..7d5d3e4 --- /dev/null +++ b/src/routes/dashboard.rs @@ -0,0 +1,68 @@ +use askama::Template; +use axum::{ + Router, + extract::State, + response::{Html, IntoResponse}, + routing::get, +}; + +use crate::auth::CurrentUser; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "dashboard.html")] +struct DashboardTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + active_instance: Option, + container_state: String, +} + +#[derive(Clone)] +struct ActiveInstanceInfo { + name: String, + version: String, + server_type: String, + max_players: i32, +} + +pub fn router() -> Router { + Router::new().route("/", get(dashboard)) +} + +async fn dashboard( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result { + let (active_info, container_state) = { + let conn = state.db.conn(); + let result = conn.query_row( + "SELECT name, version, server_type, max_players, container_state + FROM instances WHERE is_active = 1", + [], + |row| { + Ok(( + Some(ActiveInstanceInfo { + name: row.get(0)?, + version: row.get(1)?, + server_type: row.get(2)?, + max_players: row.get(3)?, + }), + row.get::<_, String>(4)?, + )) + }, + ); + result.unwrap_or((None, "stopped".into())) + }; + + let template = DashboardTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + active_instance: active_info, + container_state, + }; + + Ok(Html(template.render().unwrap_or_default())) +} 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) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..1cb0e54 --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,75 @@ +pub mod accounts; +pub mod backups; +pub mod dashboard; +pub mod instances; +pub mod players; + +use askama::Template; +use axum::{Router, response::Html}; +use tower_http::services::ServeDir; + +use crate::auth::CurrentUser; +use crate::config::Config; +use crate::db::Database; +use crate::docker::DockerManager; + +#[derive(Clone)] +pub struct AppState { + pub config: Config, + pub db: Database, + pub docker: DockerManager, +} + +pub fn build_router(state: AppState) -> Router { + let auth_required = + axum::middleware::from_fn_with_state(state.clone(), crate::auth::require_auth); + + let protected = Router::new() + .route("/change-password", axum::routing::get(change_password_page)) + .route( + "/change-password", + axum::routing::post(accounts::change_password), + ) + .merge(dashboard::router()) + .merge(instances::router()) + .merge(players::router()) + .merge(accounts::router()) + .merge(backups::router()) + .layer(auth_required); + + Router::new() + .route("/login", axum::routing::get(login_page)) + .route("/login", axum::routing::post(crate::auth::authenticate)) + .route("/logout", axum::routing::post(crate::auth::logout)) + .merge(protected) + .nest_service("/static", ServeDir::new("static")) + .with_state(state) +} + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginPageTemplate; + +async fn login_page() -> Html { + let template = LoginPageTemplate; + Html(template.render().unwrap_or_default()) +} + +#[derive(Template)] +#[template(path = "change_password.html")] +struct ChangePasswordPageTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + error: String, +} + +async fn change_password_page(CurrentUser(account): CurrentUser) -> Html { + let template = ChangePasswordPageTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + error: String::new(), + }; + Html(template.render().unwrap_or_default()) +} diff --git a/src/routes/players.rs b/src/routes/players.rs new file mode 100644 index 0000000..b98a39d --- /dev/null +++ b/src/routes/players.rs @@ -0,0 +1,131 @@ +use askama::Template; +use axum::{ + Form, Router, + extract::State, + response::{Html, Redirect}, + routing::{get, post}, +}; +use serde::Deserialize; + +use crate::auth::CurrentUser; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "players/manage.html")] +struct PlayerManagementTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + active_instance_name: Option, + whitelist: Vec, + ops: Vec, + bans: Vec, + message: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PlayerAction { + pub username: String, +} + +pub fn router() -> Router { + Router::new() + .route("/players", get(manage_players)) + .route("/players/whitelist/add", post(add_whitelist)) + .route("/players/whitelist/remove", post(remove_whitelist)) + .route("/players/op/add", post(add_op)) + .route("/players/op/remove", post(remove_op)) + .route("/players/ban/add", post(add_ban)) + .route("/players/ban/remove", post(remove_ban)) +} + +async fn manage_players( + State(state): State, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let active = get_active_instance(&state.db); + + let (instance_name, whitelist, ops, bans) = + if let Some((_id, inst_name, _rcon_pw, _container_state)) = active { + let data_dir = state + .config + .server_directory + .join("instances") + .join(&inst_name) + .join("data"); + + let w = read_json_list(&data_dir.join("whitelist.json")); + let o = read_json_list(&data_dir.join("ops.json")); + let b = read_json_list(&data_dir.join("banned-players.json")); + (Some(inst_name), w, o, b) + } else { + (None, vec![], vec![], vec![]) + }; + + let template = PlayerManagementTemplate { + username: account.username, + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + active_instance_name: instance_name, + whitelist, + ops, + bans, + message: None, + }; + + Ok(Html(template.render().unwrap_or_default())) +} + +fn read_json_list(path: &std::path::Path) -> Vec { + if let Ok(contents) = std::fs::read_to_string(path) { + if let Ok(entries) = serde_json::from_str::>(&contents) { + return entries + .iter() + .filter_map(|e| e["name"].as_str().map(String::from)) + .collect(); + } + } + vec![] +} + +macro_rules! player_action { + ($name:ident, $command:expr) => { + async fn $name( + State(state): State, + CurrentUser(_user): CurrentUser, + Form(form): Form, + ) -> Result { + if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) { + let client = crate::rcon::RconClient::new(rcon_pw); + let cmd = format!($command, form.username); + match client.send_command_async(cmd.clone()).await { + Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()), + Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e), + } + } else { + tracing::warn!( + "Player action '{}' called but no active instance", + stringify!($name) + ); + } + Ok(Redirect::to("/players")) + } + }; +} + +player_action!(add_whitelist, "whitelist add {}"); +player_action!(remove_whitelist, "whitelist remove {}"); +player_action!(add_op, "op {}"); +player_action!(remove_op, "deop {}"); +player_action!(add_ban, "ban {}"); +player_action!(remove_ban, "pardon {}"); + +fn get_active_instance(db: &crate::db::Database) -> Option<(i64, String, String, String)> { + db.conn() + .query_row( + "SELECT id, name, rcon_password, container_state FROM instances WHERE is_active = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .ok() +} -- cgit v1.2.3