From 9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 21:35:25 +0200 Subject: second stable --- src/main.rs | 1 + src/properties.rs | 39 +++++++ src/routes/backups.rs | 58 ----------- src/routes/instances.rs | 265 ++++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 src/properties.rs (limited to 'src') diff --git a/src/main.rs b/src/main.rs index a96b41b..8cbad70 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod config; mod db; mod docker; mod models; +mod properties; mod rcon; mod routes; diff --git a/src/properties.rs b/src/properties.rs new file mode 100644 index 0000000..de391cc --- /dev/null +++ b/src/properties.rs @@ -0,0 +1,39 @@ +use std::collections::HashMap; +use std::path::Path; + +/// Parse a Java .properties file into key-value pairs. +pub fn read_properties(path: &Path) -> HashMap { + let mut map = HashMap::new(); + let Ok(contents) = std::fs::read_to_string(path) else { + return map; + }; + for line in contents.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with('!') { + continue; + } + if let Some((key, value)) = line.split_once('=') { + map.insert(key.trim().to_string(), value.trim().to_string()); + } else if let Some((key, value)) = line.split_once(':') { + map.insert(key.trim().to_string(), value.trim().to_string()); + } + } + map +} + +/// Read the level-seed from an instance's server.properties. +/// Returns None if the file doesn't exist or the seed is empty. +pub fn read_instance_seed(server_dir: &Path, instance_name: &str) -> Option { + let props_path = server_dir + .join("instances") + .join(instance_name) + .join("data") + .join("server.properties"); + let props = read_properties(&props_path); + let seed = props.get("level-seed")?; + if seed.is_empty() { + None + } else { + Some(seed.clone()) + } +} diff --git a/src/routes/backups.rs b/src/routes/backups.rs index 09298d2..6b4dd49 100644 --- a/src/routes/backups.rs +++ b/src/routes/backups.rs @@ -37,7 +37,6 @@ pub fn router() -> Router { .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( @@ -197,60 +196,3 @@ async fn download_backup( .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/instances.rs b/src/routes/instances.rs index 2da1319..e4f62f0 100644 --- a/src/routes/instances.rs +++ b/src/routes/instances.rs @@ -1,7 +1,7 @@ use askama::Template; use axum::{ Form, Router, - extract::{Path, State}, + extract::{Multipart, Path, State}, response::{Html, Redirect}, routing::{get, post}, }; @@ -40,6 +40,7 @@ struct EditInstanceTemplate { is_superuser: bool, password_change_required: bool, instance: Instance, + current_seed: Option, max_memory_mb: u32, error: Option, } @@ -89,6 +90,9 @@ pub fn router() -> Router { .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( @@ -169,6 +173,7 @@ async fn create_instance( 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( @@ -180,7 +185,7 @@ async fn create_instance( form.version, form.server_type, form.world_source, - form.seed, + seed, level_type, form.memory_mb, form.difficulty, @@ -205,14 +210,15 @@ async fn create_instance( 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 conn = state.db.conn(); - let instance = conn - .query_row( + 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 @@ -241,14 +247,41 @@ async fn edit_form( }) }, ) - .map_err(|_| Redirect::to("/instances"))?; - drop(conn); + .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, error: None, }; @@ -256,7 +289,6 @@ async fn edit_form( Ok(Html(template.render().unwrap_or_default())) } -#[axum::debug_handler] async fn edit_instance( State(state): State, Path(id): Path, @@ -482,3 +514,220 @@ async fn fetch_minecraft_versions() -> Result, String> { 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"))) +} -- cgit v1.2.3