From 9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 21:35:25 +0200 Subject: second stable --- Cargo.lock | 24 ++++ Cargo.toml | 2 +- src/main.rs | 1 + src/properties.rs | 39 +++++++ src/routes/backups.rs | 58 --------- src/routes/instances.rs | 265 ++++++++++++++++++++++++++++++++++++++++-- templates/backups/list.html | 3 +- templates/instances/edit.html | 30 ++++- 8 files changed, 352 insertions(+), 70 deletions(-) create mode 100644 src/properties.rs diff --git a/Cargo.lock b/Cargo.lock index 749c521..c36739d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -1483,6 +1484,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2279,6 +2297,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index b13668d..828d45d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = { version = "0.8.9", features = ["macros"] } +axum = { version = "0.8.9", features = ["macros", "multipart"] } tokio = { version = "1.52.3", features = ["full"] } askama = "0.16" rusqlite = { version = "0.40.1", features = ["bundled"] } 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"))) +} diff --git a/templates/backups/list.html b/templates/backups/list.html index d7508eb..d97e98d 100644 --- a/templates/backups/list.html +++ b/templates/backups/list.html @@ -6,7 +6,6 @@
- Export World Back to Instances @@ -30,7 +29,7 @@ {{ backup.filename }} {{ backup.created_at }} - Download + Download
diff --git a/templates/instances/edit.html b/templates/instances/edit.html index 356c7cd..b6c982c 100644 --- a/templates/instances/edit.html +++ b/templates/instances/edit.html @@ -7,7 +7,7 @@ Version: {{ instance.version }} Type: {{ instance.server_type }} World: {{ instance.world_source }} - {% if let Some(seed) = instance.world_seed %} + {% if let Some(seed) = current_seed %} Seed: {{ seed }} {% endif %} {% if instance.has_modpack %} @@ -59,4 +59,32 @@ Cancel + +
+

World Management

+ +
+ Current seed: + {% if let Some(seed) = current_seed %} + {{ seed }} + {% else %} + not yet generated + {% endif %} + +
+ +
+ Export World + +
+ + +
+ +
+ + +
+
+
{% endblock %} -- cgit v1.2.3