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 /src/routes/instances.rs | |
| parent | first stable (diff) | |
| download | minecraft-ui-9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465.tar.gz minecraft-ui-9ad52d0151086252e6d8a3e60dc5b8cd4bfd5465.zip | |
second stable
Diffstat (limited to '')
| -rw-r--r-- | src/routes/instances.rs | 265 |
1 files changed, 257 insertions, 8 deletions
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 | } | ||
