summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main.rs1
-rw-r--r--src/properties.rs39
-rw-r--r--src/routes/backups.rs58
-rw-r--r--src/routes/instances.rs265
4 files changed, 297 insertions, 66 deletions
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;
3mod db; 3mod db;
4mod docker; 4mod docker;
5mod models; 5mod models;
6mod properties;
6mod rcon; 7mod rcon;
7mod routes; 8mod 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 @@
1use std::collections::HashMap;
2use std::path::Path;
3
4/// Parse a Java .properties file into key-value pairs.
5pub 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.
26pub 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
43async fn list_backups( 42async 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
201async 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 @@
1use askama::Template; 1use askama::Template;
2use axum::{ 2use 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
94async fn list_instances( 98async 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]
208async fn edit_form( 214async 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]
260async fn edit_instance( 292async 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)]
521pub struct RegenerateWorldForm {
522 pub seed: Option<String>,
523}
524
525async 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
568fn 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]
596async 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
666async 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}