From 9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465 Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 12 Jul 2026 21:35:25 +0200 Subject: second stable --- src/routes/instances.rs | 265 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 257 insertions(+), 8 deletions(-) (limited to 'src/routes/instances.rs') 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