diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-12 21:35:25 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-12 21:35:25 +0200 |
| commit | 9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465 (patch) | |
| tree | 2e638c274e0ab9dc4f3812e8ece6c603d854e1fd | |
| parent | first stable (diff) | |
| download | minecraft-ui-9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465.tar.gz minecraft-ui-9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465.zip | |
second stable
| -rw-r--r-- | Cargo.lock | 24 | ||||
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/properties.rs | 39 | ||||
| -rw-r--r-- | src/routes/backups.rs | 58 | ||||
| -rw-r--r-- | src/routes/instances.rs | 265 | ||||
| -rw-r--r-- | templates/backups/list.html | 3 | ||||
| -rw-r--r-- | templates/instances/edit.html | 30 |
8 files changed, 352 insertions, 70 deletions
| @@ -192,6 +192,7 @@ dependencies = [ | |||
| 192 | "matchit", | 192 | "matchit", |
| 193 | "memchr", | 193 | "memchr", |
| 194 | "mime", | 194 | "mime", |
| 195 | "multer", | ||
| 195 | "percent-encoding", | 196 | "percent-encoding", |
| 196 | "pin-project-lite", | 197 | "pin-project-lite", |
| 197 | "serde_core", | 198 | "serde_core", |
| @@ -1484,6 +1485,23 @@ dependencies = [ | |||
| 1484 | ] | 1485 | ] |
| 1485 | 1486 | ||
| 1486 | [[package]] | 1487 | [[package]] |
| 1488 | name = "multer" | ||
| 1489 | version = "3.1.0" | ||
| 1490 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1491 | checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" | ||
| 1492 | dependencies = [ | ||
| 1493 | "bytes", | ||
| 1494 | "encoding_rs", | ||
| 1495 | "futures-util", | ||
| 1496 | "http", | ||
| 1497 | "httparse", | ||
| 1498 | "memchr", | ||
| 1499 | "mime", | ||
| 1500 | "spin", | ||
| 1501 | "version_check", | ||
| 1502 | ] | ||
| 1503 | |||
| 1504 | [[package]] | ||
| 1487 | name = "nu-ansi-term" | 1505 | name = "nu-ansi-term" |
| 1488 | version = "0.50.3" | 1506 | version = "0.50.3" |
| 1489 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1507 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -2280,6 +2298,12 @@ dependencies = [ | |||
| 2280 | ] | 2298 | ] |
| 2281 | 2299 | ||
| 2282 | [[package]] | 2300 | [[package]] |
| 2301 | name = "spin" | ||
| 2302 | version = "0.9.8" | ||
| 2303 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 2304 | checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" | ||
| 2305 | |||
| 2306 | [[package]] | ||
| 2283 | name = "sqlite-wasm-rs" | 2307 | name = "sqlite-wasm-rs" |
| 2284 | version = "0.5.5" | 2308 | version = "0.5.5" |
| 2285 | source = "registry+https://github.com/rust-lang/crates.io-index" | 2309 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -4,7 +4,7 @@ version = "0.1.0" | |||
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [dependencies] | 6 | [dependencies] |
| 7 | axum = { version = "0.8.9", features = ["macros"] } | 7 | axum = { version = "0.8.9", features = ["macros", "multipart"] } |
| 8 | tokio = { version = "1.52.3", features = ["full"] } | 8 | tokio = { version = "1.52.3", features = ["full"] } |
| 9 | askama = "0.16" | 9 | askama = "0.16" |
| 10 | rusqlite = { version = "0.40.1", features = ["bundled"] } | 10 | 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; | |||
| 3 | mod db; | 3 | mod db; |
| 4 | mod docker; | 4 | mod docker; |
| 5 | mod models; | 5 | mod models; |
| 6 | mod properties; | ||
| 6 | mod rcon; | 7 | mod rcon; |
| 7 | mod routes; | 8 | mod routes; |
| 8 | 9 | ||
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 @@ | |||
| 1 | use std::collections::HashMap; | ||
| 2 | use std::path::Path; | ||
| 3 | |||
| 4 | /// Parse a Java .properties file into key-value pairs. | ||
| 5 | pub fn read_properties(path: &Path) -> HashMap<String, String> { | ||
| 6 | let mut map = HashMap::new(); | ||
| 7 | let Ok(contents) = std::fs::read_to_string(path) else { | ||
| 8 | return map; | ||
| 9 | }; | ||
| 10 | for line in contents.lines() { | ||
| 11 | let line = line.trim(); | ||
| 12 | if line.is_empty() || line.starts_with('#') || line.starts_with('!') { | ||
| 13 | continue; | ||
| 14 | } | ||
| 15 | if let Some((key, value)) = line.split_once('=') { | ||
| 16 | map.insert(key.trim().to_string(), value.trim().to_string()); | ||
| 17 | } else if let Some((key, value)) = line.split_once(':') { | ||
| 18 | map.insert(key.trim().to_string(), value.trim().to_string()); | ||
| 19 | } | ||
| 20 | } | ||
| 21 | map | ||
| 22 | } | ||
| 23 | |||
| 24 | /// Read the level-seed from an instance's server.properties. | ||
| 25 | /// Returns None if the file doesn't exist or the seed is empty. | ||
| 26 | pub fn read_instance_seed(server_dir: &Path, instance_name: &str) -> Option<String> { | ||
| 27 | let props_path = server_dir | ||
| 28 | .join("instances") | ||
| 29 | .join(instance_name) | ||
| 30 | .join("data") | ||
| 31 | .join("server.properties"); | ||
| 32 | let props = read_properties(&props_path); | ||
| 33 | let seed = props.get("level-seed")?; | ||
| 34 | if seed.is_empty() { | ||
| 35 | None | ||
| 36 | } else { | ||
| 37 | Some(seed.clone()) | ||
| 38 | } | ||
| 39 | } | ||
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<AppState> { | |||
| 37 | .route("/instances/{id}/backups/create", post(create_backup)) | 37 | .route("/instances/{id}/backups/create", post(create_backup)) |
| 38 | .route("/backups/{id}/restore", post(restore_backup)) | 38 | .route("/backups/{id}/restore", post(restore_backup)) |
| 39 | .route("/backups/{id}/download", get(download_backup)) | 39 | .route("/backups/{id}/download", get(download_backup)) |
| 40 | .route("/instances/{id}/world/export", get(export_world)) | ||
| 41 | } | 40 | } |
| 42 | 41 | ||
| 43 | async fn list_backups( | 42 | async fn list_backups( |
| @@ -197,60 +196,3 @@ async fn download_backup( | |||
| 197 | .body(Body::from(content)) | 196 | .body(Body::from(content)) |
| 198 | .unwrap()) | 197 | .unwrap()) |
| 199 | } | 198 | } |
| 200 | |||
| 201 | async fn export_world( | ||
| 202 | State(state): State<AppState>, | ||
| 203 | Path(instance_id): Path<i64>, | ||
| 204 | CurrentUser(_user): CurrentUser, | ||
| 205 | ) -> Result<Response, (StatusCode, &'static str)> { | ||
| 206 | let conn = state.db.conn(); | ||
| 207 | let instance_name: String = conn | ||
| 208 | .query_row( | ||
| 209 | "SELECT name FROM instances WHERE id = ?1", | ||
| 210 | params![instance_id], | ||
| 211 | |row| row.get(0), | ||
| 212 | ) | ||
| 213 | .map_err(|_| (StatusCode::NOT_FOUND, "Instance not found"))?; | ||
| 214 | drop(conn); | ||
| 215 | |||
| 216 | let world_dir = state | ||
| 217 | .config | ||
| 218 | .server_directory | ||
| 219 | .join("instances") | ||
| 220 | .join(&instance_name) | ||
| 221 | .join("data") | ||
| 222 | .join("world"); | ||
| 223 | |||
| 224 | let mut zip_buf = Vec::new(); | ||
| 225 | { | ||
| 226 | let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); | ||
| 227 | let options: FileOptions<'_, ()> = | ||
| 228 | FileOptions::default().compression_method(zip::CompressionMethod::Deflated); | ||
| 229 | |||
| 230 | if let Ok(entries) = std::fs::read_dir(&world_dir) { | ||
| 231 | for entry in entries.flatten() { | ||
| 232 | let path = entry.path(); | ||
| 233 | if path.is_file() { | ||
| 234 | if let Some(name) = path.file_name() { | ||
| 235 | if let Ok(contents) = std::fs::read(&path) { | ||
| 236 | let _ = zip.start_file(&*name.to_string_lossy(), options); | ||
| 237 | let _ = std::io::Write::write_all(&mut zip, &contents); | ||
| 238 | } | ||
| 239 | } | ||
| 240 | } | ||
| 241 | } | ||
| 242 | } | ||
| 243 | let _ = zip.finish(); | ||
| 244 | } | ||
| 245 | |||
| 246 | let filename = format!("{instance_name}_world.zip"); | ||
| 247 | |||
| 248 | Ok(Response::builder() | ||
| 249 | .header(header::CONTENT_TYPE, "application/zip") | ||
| 250 | .header( | ||
| 251 | header::CONTENT_DISPOSITION, | ||
| 252 | format!("attachment; filename=\"{filename}\""), | ||
| 253 | ) | ||
| 254 | .body(Body::from(zip_buf)) | ||
| 255 | .unwrap()) | ||
| 256 | } | ||
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 @@ | |||
| 1 | use askama::Template; | 1 | use askama::Template; |
| 2 | use axum::{ | 2 | use axum::{ |
| 3 | Form, Router, | 3 | Form, Router, |
| 4 | extract::{Path, State}, | 4 | extract::{Multipart, Path, State}, |
| 5 | response::{Html, Redirect}, | 5 | response::{Html, Redirect}, |
| 6 | routing::{get, post}, | 6 | routing::{get, post}, |
| 7 | }; | 7 | }; |
| @@ -40,6 +40,7 @@ struct EditInstanceTemplate { | |||
| 40 | is_superuser: bool, | 40 | is_superuser: bool, |
| 41 | password_change_required: bool, | 41 | password_change_required: bool, |
| 42 | instance: Instance, | 42 | instance: Instance, |
| 43 | current_seed: Option<String>, | ||
| 43 | max_memory_mb: u32, | 44 | max_memory_mb: u32, |
| 44 | error: Option<String>, | 45 | error: Option<String>, |
| 45 | } | 46 | } |
| @@ -89,6 +90,9 @@ pub fn router() -> Router<AppState> { | |||
| 89 | .route("/instances/{id}/delete", post(delete_instance)) | 90 | .route("/instances/{id}/delete", post(delete_instance)) |
| 90 | .route("/instances/{id}/activate", post(activate_instance)) | 91 | .route("/instances/{id}/activate", post(activate_instance)) |
| 91 | .route("/instances/deactivate", post(deactivate_instance)) | 92 | .route("/instances/deactivate", post(deactivate_instance)) |
| 93 | .route("/instances/{id}/world/export", get(export_world)) | ||
| 94 | .route("/instances/{id}/world/import", post(import_world)) | ||
| 95 | .route("/instances/{id}/world/regenerate", post(regenerate_world)) | ||
| 92 | } | 96 | } |
| 93 | 97 | ||
| 94 | async fn list_instances( | 98 | async fn list_instances( |
| @@ -169,6 +173,7 @@ async fn create_instance( | |||
| 169 | let pvp = form.pvp.as_deref() == Some("on"); | 173 | let pvp = form.pvp.as_deref() == Some("on"); |
| 170 | let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into()); | 174 | let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into()); |
| 171 | let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into()); | 175 | let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into()); |
| 176 | let seed = form.seed.filter(|s| !s.is_empty()); | ||
| 172 | 177 | ||
| 173 | let conn = state.db.conn(); | 178 | let conn = state.db.conn(); |
| 174 | conn.execute( | 179 | conn.execute( |
| @@ -180,7 +185,7 @@ async fn create_instance( | |||
| 180 | form.version, | 185 | form.version, |
| 181 | form.server_type, | 186 | form.server_type, |
| 182 | form.world_source, | 187 | form.world_source, |
| 183 | form.seed, | 188 | seed, |
| 184 | level_type, | 189 | level_type, |
| 185 | form.memory_mb, | 190 | form.memory_mb, |
| 186 | form.difficulty, | 191 | form.difficulty, |
| @@ -205,14 +210,15 @@ async fn create_instance( | |||
| 205 | Ok(Redirect::to("/instances")) | 210 | Ok(Redirect::to("/instances")) |
| 206 | } | 211 | } |
| 207 | 212 | ||
| 213 | #[axum::debug_handler] | ||
| 208 | async fn edit_form( | 214 | async fn edit_form( |
| 209 | State(state): State<AppState>, | 215 | State(state): State<AppState>, |
| 210 | Path(id): Path<i64>, | 216 | Path(id): Path<i64>, |
| 211 | CurrentUser(account): CurrentUser, | 217 | CurrentUser(account): CurrentUser, |
| 212 | ) -> Result<Html<String>, axum::response::Redirect> { | 218 | ) -> Result<Html<String>, axum::response::Redirect> { |
| 213 | let conn = state.db.conn(); | 219 | let instance = { |
| 214 | let instance = conn | 220 | let conn = state.db.conn(); |
| 215 | .query_row( | 221 | conn.query_row( |
| 216 | "SELECT id, name, version, server_type, world_source, world_seed, level_type, | 222 | "SELECT id, name, version, server_type, world_source, world_seed, level_type, |
| 217 | has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, | 223 | has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, |
| 218 | rcon_password, is_active, created_at, updated_at | 224 | rcon_password, is_active, created_at, updated_at |
| @@ -241,14 +247,41 @@ async fn edit_form( | |||
| 241 | }) | 247 | }) |
| 242 | }, | 248 | }, |
| 243 | ) | 249 | ) |
| 244 | .map_err(|_| Redirect::to("/instances"))?; | 250 | .map_err(|_| Redirect::to("/instances"))? |
| 245 | drop(conn); | 251 | }; |
| 252 | |||
| 253 | let db_seed = instance.world_seed.clone().filter(|s| !s.is_empty()); | ||
| 254 | let current_seed = | ||
| 255 | crate::properties::read_instance_seed(&state.config.server_directory, &instance.name) | ||
| 256 | .or(db_seed); | ||
| 257 | |||
| 258 | // If no seed known yet, try RCON /seed if the server is running | ||
| 259 | let current_seed = if current_seed.is_none() && instance.is_active { | ||
| 260 | let rcon = crate::rcon::RconClient::new(instance.rcon_password.clone()); | ||
| 261 | match rcon.send_command_async("seed".into()).await { | ||
| 262 | Ok(resp) => { | ||
| 263 | let s = resp.trim(); | ||
| 264 | // Format: "Seed: [1234567890]" | ||
| 265 | s.strip_prefix("Seed: [") | ||
| 266 | .and_then(|r| r.strip_suffix(']')) | ||
| 267 | .or_else(|| s.strip_prefix("Seed: ")) | ||
| 268 | .map(|v| v.trim().to_string()) | ||
| 269 | } | ||
| 270 | Err(e) => { | ||
| 271 | tracing::debug!("RCON /seed failed: {e}"); | ||
| 272 | None | ||
| 273 | } | ||
| 274 | } | ||
| 275 | } else { | ||
| 276 | current_seed | ||
| 277 | }; | ||
| 246 | 278 | ||
| 247 | let template = EditInstanceTemplate { | 279 | let template = EditInstanceTemplate { |
| 248 | username: account.username.clone(), | 280 | username: account.username.clone(), |
| 249 | is_superuser: account.is_superuser, | 281 | is_superuser: account.is_superuser, |
| 250 | password_change_required: account.password_change_required, | 282 | password_change_required: account.password_change_required, |
| 251 | instance, | 283 | instance, |
| 284 | current_seed, | ||
| 252 | max_memory_mb: state.config.max_memory_mb, | 285 | max_memory_mb: state.config.max_memory_mb, |
| 253 | error: None, | 286 | error: None, |
| 254 | }; | 287 | }; |
| @@ -256,7 +289,6 @@ async fn edit_form( | |||
| 256 | Ok(Html(template.render().unwrap_or_default())) | 289 | Ok(Html(template.render().unwrap_or_default())) |
| 257 | } | 290 | } |
| 258 | 291 | ||
| 259 | #[axum::debug_handler] | ||
| 260 | async fn edit_instance( | 292 | async fn edit_instance( |
| 261 | State(state): State<AppState>, | 293 | State(state): State<AppState>, |
| 262 | Path(id): Path<i64>, | 294 | Path(id): Path<i64>, |
| @@ -482,3 +514,220 @@ async fn fetch_minecraft_versions() -> Result<Vec<String>, String> { | |||
| 482 | 514 | ||
| 483 | Ok(versions) | 515 | Ok(versions) |
| 484 | } | 516 | } |
| 517 | |||
| 518 | // ── World management ────────────────────────────────────────────── | ||
| 519 | |||
| 520 | #[derive(Debug, Deserialize)] | ||
| 521 | pub struct RegenerateWorldForm { | ||
| 522 | pub seed: Option<String>, | ||
| 523 | } | ||
| 524 | |||
| 525 | async fn export_world( | ||
| 526 | State(state): State<AppState>, | ||
| 527 | Path(id): Path<i64>, | ||
| 528 | CurrentUser(_user): CurrentUser, | ||
| 529 | ) -> Result<axum::response::Response, axum::response::Redirect> { | ||
| 530 | let conn = state.db.conn(); | ||
| 531 | let instance_name: String = conn | ||
| 532 | .query_row( | ||
| 533 | "SELECT name FROM instances WHERE id = ?1", | ||
| 534 | params![id], | ||
| 535 | |row| row.get(0), | ||
| 536 | ) | ||
| 537 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 538 | drop(conn); | ||
| 539 | |||
| 540 | let world_dir = state | ||
| 541 | .config | ||
| 542 | .server_directory | ||
| 543 | .join("instances") | ||
| 544 | .join(&instance_name) | ||
| 545 | .join("data") | ||
| 546 | .join("world"); | ||
| 547 | |||
| 548 | let mut zip_buf = Vec::new(); | ||
| 549 | { | ||
| 550 | let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); | ||
| 551 | let options: zip::write::FileOptions<'_, ()> = | ||
| 552 | zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Deflated); | ||
| 553 | add_dir_to_zip(&mut zip, &world_dir, "", options)?; | ||
| 554 | zip.finish().ok(); | ||
| 555 | } | ||
| 556 | |||
| 557 | let filename = format!("{instance_name}_world.zip"); | ||
| 558 | Ok(axum::response::Response::builder() | ||
| 559 | .header(axum::http::header::CONTENT_TYPE, "application/zip") | ||
| 560 | .header( | ||
| 561 | axum::http::header::CONTENT_DISPOSITION, | ||
| 562 | format!("attachment; filename=\"{filename}\""), | ||
| 563 | ) | ||
| 564 | .body(axum::body::Body::from(zip_buf)) | ||
| 565 | .unwrap()) | ||
| 566 | } | ||
| 567 | |||
| 568 | fn add_dir_to_zip( | ||
| 569 | zip: &mut zip::ZipWriter<std::io::Cursor<&mut Vec<u8>>>, | ||
| 570 | dir: &std::path::Path, | ||
| 571 | prefix: &str, | ||
| 572 | options: zip::write::FileOptions<'_, ()>, | ||
| 573 | ) -> Result<(), axum::response::Redirect> { | ||
| 574 | if let Ok(entries) = std::fs::read_dir(dir) { | ||
| 575 | for entry in entries.flatten() { | ||
| 576 | let path = entry.path(); | ||
| 577 | let name = path.file_name().unwrap().to_string_lossy(); | ||
| 578 | let zip_path = if prefix.is_empty() { | ||
| 579 | name.to_string() | ||
| 580 | } else { | ||
| 581 | format!("{prefix}/{name}") | ||
| 582 | }; | ||
| 583 | if path.is_dir() { | ||
| 584 | zip.add_directory(&zip_path, options).ok(); | ||
| 585 | add_dir_to_zip(zip, &path, &zip_path, options)?; | ||
| 586 | } else if let Ok(contents) = std::fs::read(&path) { | ||
| 587 | zip.start_file(&zip_path, options).ok(); | ||
| 588 | std::io::Write::write_all(zip, &contents).ok(); | ||
| 589 | } | ||
| 590 | } | ||
| 591 | } | ||
| 592 | Ok(()) | ||
| 593 | } | ||
| 594 | |||
| 595 | #[axum::debug_handler] | ||
| 596 | async fn import_world( | ||
| 597 | State(state): State<AppState>, | ||
| 598 | Path(id): Path<i64>, | ||
| 599 | CurrentUser(_user): CurrentUser, | ||
| 600 | mut multipart: Multipart, | ||
| 601 | ) -> Result<Redirect, axum::response::Redirect> { | ||
| 602 | let (instance_name, is_active) = { | ||
| 603 | let conn = state.db.conn(); | ||
| 604 | let row = conn.query_row( | ||
| 605 | "SELECT name, is_active FROM instances WHERE id = ?1", | ||
| 606 | params![id], | ||
| 607 | |row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)? != 0)), | ||
| 608 | ); | ||
| 609 | match row { | ||
| 610 | Ok(v) => v, | ||
| 611 | Err(_) => return Ok(Redirect::to("/instances")), | ||
| 612 | } | ||
| 613 | }; | ||
| 614 | |||
| 615 | if is_active { | ||
| 616 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | ||
| 617 | } | ||
| 618 | |||
| 619 | let data_dir = state | ||
| 620 | .config | ||
| 621 | .server_directory | ||
| 622 | .join("instances") | ||
| 623 | .join(&instance_name) | ||
| 624 | .join("data"); | ||
| 625 | |||
| 626 | // Extract uploaded ZIP | ||
| 627 | while let Ok(Some(field)) = multipart.next_field().await { | ||
| 628 | let bytes = field | ||
| 629 | .bytes() | ||
| 630 | .await | ||
| 631 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | ||
| 632 | let cursor = std::io::Cursor::new(bytes); | ||
| 633 | let mut archive = zip::ZipArchive::new(cursor) | ||
| 634 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | ||
| 635 | |||
| 636 | // Remove existing world | ||
| 637 | let world_dir = data_dir.join("world"); | ||
| 638 | let _ = std::fs::remove_dir_all(&world_dir); | ||
| 639 | let _ = std::fs::create_dir_all(&world_dir); | ||
| 640 | |||
| 641 | for i in 0..archive.len() { | ||
| 642 | let mut file = archive | ||
| 643 | .by_index(i) | ||
| 644 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | ||
| 645 | let out_path = match file.enclosed_name() { | ||
| 646 | Some(p) => world_dir.join(p), | ||
| 647 | None => continue, | ||
| 648 | }; | ||
| 649 | if file.is_dir() { | ||
| 650 | let _ = std::fs::create_dir_all(&out_path); | ||
| 651 | } else { | ||
| 652 | if let Some(parent) = out_path.parent() { | ||
| 653 | let _ = std::fs::create_dir_all(parent); | ||
| 654 | } | ||
| 655 | let mut out = std::fs::File::create(&out_path) | ||
| 656 | .map_err(|_| Redirect::to(&format!("/instances/{id}/edit")))?; | ||
| 657 | std::io::copy(&mut file, &mut out).ok(); | ||
| 658 | } | ||
| 659 | } | ||
| 660 | break; // only process first file | ||
| 661 | } | ||
| 662 | |||
| 663 | Ok(Redirect::to(&format!("/instances/{id}/edit"))) | ||
| 664 | } | ||
| 665 | |||
| 666 | async fn regenerate_world( | ||
| 667 | State(state): State<AppState>, | ||
| 668 | Path(id): Path<i64>, | ||
| 669 | CurrentUser(_user): CurrentUser, | ||
| 670 | Form(form): Form<RegenerateWorldForm>, | ||
| 671 | ) -> Result<Redirect, axum::response::Redirect> { | ||
| 672 | let conn = state.db.conn(); | ||
| 673 | let (instance_name, is_active): (String, bool) = conn | ||
| 674 | .query_row( | ||
| 675 | "SELECT name, is_active FROM instances WHERE id = ?1", | ||
| 676 | params![id], | ||
| 677 | |row| Ok((row.get(0)?, row.get::<_, i32>(1)? != 0)), | ||
| 678 | ) | ||
| 679 | .map_err(|_| Redirect::to("/instances"))?; | ||
| 680 | drop(conn); | ||
| 681 | |||
| 682 | if is_active { | ||
| 683 | return Ok(Redirect::to(&format!("/instances/{id}/edit"))); | ||
| 684 | } | ||
| 685 | |||
| 686 | let props_path = state | ||
| 687 | .config | ||
| 688 | .server_directory | ||
| 689 | .join("instances") | ||
| 690 | .join(&instance_name) | ||
| 691 | .join("data") | ||
| 692 | .join("server.properties"); | ||
| 693 | |||
| 694 | // Update level-seed in server.properties | ||
| 695 | let seed = form.seed.unwrap_or_default(); | ||
| 696 | let contents = std::fs::read_to_string(&props_path).unwrap_or_default(); | ||
| 697 | let mut new_lines = Vec::new(); | ||
| 698 | let mut found = false; | ||
| 699 | for line in contents.lines() { | ||
| 700 | if line.trim_start().starts_with("level-seed") { | ||
| 701 | new_lines.push(format!("level-seed={seed}")); | ||
| 702 | found = true; | ||
| 703 | } else { | ||
| 704 | new_lines.push(line.to_string()); | ||
| 705 | } | ||
| 706 | } | ||
| 707 | if !found { | ||
| 708 | new_lines.push(format!("level-seed={seed}")); | ||
| 709 | } | ||
| 710 | let _ = std::fs::write(&props_path, new_lines.join("\n")); | ||
| 711 | |||
| 712 | // Update DB record | ||
| 713 | let db_seed: Option<&str> = if seed.is_empty() { None } else { Some(&seed) }; | ||
| 714 | let conn = state.db.conn(); | ||
| 715 | conn.execute( | ||
| 716 | "UPDATE instances SET world_seed = ?1, updated_at = datetime('now') WHERE id = ?2", | ||
| 717 | params![db_seed, id], | ||
| 718 | ) | ||
| 719 | .ok(); | ||
| 720 | drop(conn); | ||
| 721 | |||
| 722 | // Delete existing world so it gets regenerated on next start | ||
| 723 | let world_dir = state | ||
| 724 | .config | ||
| 725 | .server_directory | ||
| 726 | .join("instances") | ||
| 727 | .join(&instance_name) | ||
| 728 | .join("data") | ||
| 729 | .join("world"); | ||
| 730 | let _ = std::fs::remove_dir_all(&world_dir); | ||
| 731 | |||
| 732 | Ok(Redirect::to(&format!("/instances/{id}/edit"))) | ||
| 733 | } | ||
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 @@ | |||
| 6 | <form action="/instances/{{ instance_id }}/backups/create" method="post" style="display:inline"> | 6 | <form action="/instances/{{ instance_id }}/backups/create" method="post" style="display:inline"> |
| 7 | <button type="submit" class="btn-ok">Create Backup</button> | 7 | <button type="submit" class="btn-ok">Create Backup</button> |
| 8 | </form> | 8 | </form> |
| 9 | <a href="/instances/{{ instance_id }}/world/export" class="btn">Export World</a> | ||
| 10 | <a href="/instances" class="btn">Back to Instances</a> | 9 | <a href="/instances" class="btn">Back to Instances</a> |
| 11 | </div> | 10 | </div> |
| 12 | </div> | 11 | </div> |
| @@ -30,7 +29,7 @@ | |||
| 30 | <td>{{ backup.filename }}</td> | 29 | <td>{{ backup.filename }}</td> |
| 31 | <td>{{ backup.created_at }}</td> | 30 | <td>{{ backup.created_at }}</td> |
| 32 | <td> | 31 | <td> |
| 33 | <a href="/backups/{{ backup.id }}/download" class="btn-sm">Download</a> | 32 | <a href="/backups/{{ backup.id }}/download" class="btn-sm" hx-boost="false">Download</a> |
| 34 | <form action="/backups/{{ backup.id }}/restore" method="post" style="display:inline"> | 33 | <form action="/backups/{{ backup.id }}/restore" method="post" style="display:inline"> |
| 35 | <button type="submit" class="btn-sm btn-ok">Restore</button> | 34 | <button type="submit" class="btn-sm btn-ok">Restore</button> |
| 36 | </form> | 35 | </form> |
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 @@ | |||
| 7 | <span><strong>Version:</strong> {{ instance.version }}</span> | 7 | <span><strong>Version:</strong> {{ instance.version }}</span> |
| 8 | <span><strong>Type:</strong> {{ instance.server_type }}</span> | 8 | <span><strong>Type:</strong> {{ instance.server_type }}</span> |
| 9 | <span><strong>World:</strong> {{ instance.world_source }}</span> | 9 | <span><strong>World:</strong> {{ instance.world_source }}</span> |
| 10 | {% if let Some(seed) = instance.world_seed %} | 10 | {% if let Some(seed) = current_seed %} |
| 11 | <span><strong>Seed:</strong> {{ seed }}</span> | 11 | <span><strong>Seed:</strong> {{ seed }}</span> |
| 12 | {% endif %} | 12 | {% endif %} |
| 13 | {% if instance.has_modpack %} | 13 | {% if instance.has_modpack %} |
| @@ -59,4 +59,32 @@ | |||
| 59 | <a href="/instances" class="btn">Cancel</a> | 59 | <a href="/instances" class="btn">Cancel</a> |
| 60 | </div> | 60 | </div> |
| 61 | </form> | 61 | </form> |
| 62 | |||
| 63 | <div class="card" style="margin-top:1.5rem"> | ||
| 64 | <h3>World Management</h3> | ||
| 65 | |||
| 66 | <div class="meta" style="margin-bottom:1rem"> | ||
| 67 | <span><strong>Current seed:</strong> | ||
| 68 | {% if let Some(seed) = current_seed %} | ||
| 69 | {{ seed }} | ||
| 70 | {% else %} | ||
| 71 | <em>not yet generated</em> | ||
| 72 | {% endif %} | ||
| 73 | </span> | ||
| 74 | </div> | ||
| 75 | |||
| 76 | <div class="actions" style="flex-wrap:wrap; gap:1rem"> | ||
| 77 | <a href="/instances/{{ instance.id }}/world/export" class="btn" hx-boost="false">Export World</a> | ||
| 78 | |||
| 79 | <form action="/instances/{{ instance.id }}/world/import" method="post" enctype="multipart/form-data" style="display:flex;align-items:center;gap:0.5rem" hx-boost="false"> | ||
| 80 | <input type="file" name="world_zip" accept=".zip" required style="font-size:0.85rem"> | ||
| 81 | <button type="submit" class="btn" onclick="return confirm('This will replace the current world. Continue?')">Import</button> | ||
| 82 | </form> | ||
| 83 | |||
| 84 | <form action="/instances/{{ instance.id }}/world/regenerate" method="post" class="form-row" onsubmit="return confirm('This will DELETE the current world and generate a new one. Continue?')"> | ||
| 85 | <input type="text" name="seed" placeholder="Seed (empty = random)" style="width:180px"> | ||
| 86 | <button type="submit" class="btn-danger">Regenerate</button> | ||
| 87 | </form> | ||
| 88 | </div> | ||
| 89 | </div> | ||
| 62 | {% endblock %} | 90 | {% endblock %} |
