summaryrefslogtreecommitdiffstats
path: root/src/routes/instances.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/routes/instances.rs282
1 files changed, 268 insertions, 14 deletions
diff --git a/src/routes/instances.rs b/src/routes/instances.rs
index d3b8294..a3c4baa 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::{Multipart, Path, State}, 4 extract::{Multipart, Path, Query, State},
5 response::{Html, Redirect}, 5 response::{Html, Redirect},
6 routing::{get, post}, 6 routing::{get, post},
7}; 7};
@@ -95,6 +95,7 @@ pub fn router() -> Router<AppState> {
95 .route("/instances/{id}/world/export", get(export_world)) 95 .route("/instances/{id}/world/export", get(export_world))
96 .route("/instances/{id}/world/import", post(import_world)) 96 .route("/instances/{id}/world/import", post(import_world))
97 .route("/instances/{id}/world/regenerate", post(regenerate_world)) 97 .route("/instances/{id}/world/regenerate", post(regenerate_world))
98 .route("/instances/{id}/config", get(config_editor).post(save_config))
98} 99}
99 100
100async fn list_instances( 101async fn list_instances(
@@ -345,7 +346,53 @@ async fn edit_instance(
345 let pvp = form.pvp.as_deref() == Some("on"); 346 let pvp = form.pvp.as_deref() == Some("on");
346 let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); 347 let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on");
347 348
348 let is_active = { 349 // Read current values to detect what changed
350 let (_instance_name, is_active, old_whitelist, rcon_password): (String, bool, bool, String) = {
351 let conn = state.db.conn();
352 conn.query_row(
353 "SELECT name, is_active, whitelist_enabled, rcon_password
354 FROM instances WHERE id = ?1",
355 params![id],
356 |row| Ok((
357 row.get(0)?,
358 row.get::<_, i32>(1)? != 0,
359 row.get::<_, i32>(2)? != 0,
360 row.get(3)?,
361 )),
362 )
363 .map_err(|_| Redirect::to("/instances"))?
364 };
365
366 // Check if any restart-requiring field changed
367 let (old_difficulty, old_gamemode, old_max_players, old_pvp, old_motd, old_memory): (i32, i32, i32, bool, String, i32) = {
368 let conn = state.db.conn();
369 conn.query_row(
370 "SELECT difficulty, gamemode, max_players, pvp, motd, memory_mb
371 FROM instances WHERE id = ?1",
372 params![id],
373 |row| Ok((
374 row.get(0)?,
375 row.get(1)?,
376 row.get(2)?,
377 row.get::<_, i32>(3)? != 0,
378 row.get(4)?,
379 row.get(5)?,
380 )),
381 )
382 .map_err(|_| Redirect::to("/instances"))?
383 };
384
385 let needs_restart = form.difficulty != old_difficulty
386 || form.gamemode != old_gamemode
387 || form.max_players != old_max_players
388 || pvp != old_pvp
389 || form.motd.as_deref().unwrap_or("") != old_motd
390 || form.memory_mb != old_memory;
391
392 let whitelist_changed = whitelist_enabled != old_whitelist;
393
394 // Persist changes
395 {
349 let conn = state.db.conn(); 396 let conn = state.db.conn();
350 conn.execute( 397 conn.execute(
351 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, 398 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3,
@@ -363,17 +410,23 @@ async fn edit_instance(
363 ], 410 ],
364 ) 411 )
365 .map_err(|_| Redirect::to("/instances"))?; 412 .map_err(|_| Redirect::to("/instances"))?;
366 413 }
367 conn.query_row(
368 "SELECT is_active FROM instances WHERE id = ?1",
369 params![id],
370 |row| row.get::<_, i32>(0).map(|v| v != 0),
371 )
372 .unwrap_or(false)
373 };
374 414
375 if is_active { 415 if is_active {
376 let _ = state.docker.stop_container().await; 416 // Apply whitelist change live via RCON if it was the only change
417 if whitelist_changed {
418 let cmd = if whitelist_enabled { "whitelist on" } else { "whitelist off" };
419 let rcon = crate::rcon::RconClient::new(rcon_password);
420 match rcon.send_command_async(cmd.into()).await {
421 Ok(resp) => tracing::info!("RCON {cmd}: {resp}"),
422 Err(e) => tracing::error!("RCON {cmd} failed: {e}"),
423 }
424 }
425
426 // If other settings changed, restart the container
427 if needs_restart {
428 let _ = state.docker.stop_container().await;
429 }
377 } 430 }
378 431
379 Ok(Redirect::to("/instances")) 432 Ok(Redirect::to("/instances"))
@@ -714,8 +767,8 @@ async fn import_world(
714 .join(&safe_name) 767 .join(&safe_name)
715 .join("data"); 768 .join("data");
716 769
717 // Extract uploaded ZIP 770 // Extract uploaded ZIP (only the first file is processed)
718 while let Ok(Some(field)) = multipart.next_field().await { 771 if let Ok(Some(field)) = multipart.next_field().await {
719 let bytes = field 772 let bytes = field
720 .bytes() 773 .bytes()
721 .await 774 .await
@@ -754,12 +807,213 @@ async fn import_world(
754 std::io::copy(&mut file, &mut out).ok(); 807 std::io::copy(&mut file, &mut out).ok();
755 } 808 }
756 } 809 }
757 break; // only process first file
758 } 810 }
759 811
760 Ok(Redirect::to(&format!("/instances/{id}/edit"))) 812 Ok(Redirect::to(&format!("/instances/{id}/edit")))
761} 813}
762 814
815#[derive(Template)]
816#[template(path = "instances/config.html")]
817struct ConfigEditorTemplate {
818 username: String,
819 is_superuser: bool,
820 password_change_required: bool,
821 instance_id: i64,
822 instance_name: String,
823 files: Vec<String>,
824 selected_file: Option<String>,
825 content: Option<String>,
826 error: Option<String>,
827 saved: bool,
828}
829
830#[derive(Debug, Deserialize)]
831struct ConfigPathQuery {
832 path: Option<String>,
833}
834
835#[derive(Debug, Deserialize)]
836struct SaveConfigForm {
837 path: String,
838 content: String,
839}
840
841/// Browse and edit config files in the instance's config/ directory.
842async fn config_editor(
843 State(state): State<AppState>,
844 Path(instance_id): Path<i64>,
845 CurrentUser(account): CurrentUser,
846 Query(query): Query<ConfigPathQuery>,
847) -> Result<Html<String>, axum::response::Redirect> {
848 let conn = state.db.conn();
849 let instance_name: String = conn
850 .query_row(
851 "SELECT name FROM instances WHERE id = ?1",
852 params![instance_id],
853 |row| row.get(0),
854 )
855 .map_err(|_| Redirect::to("/instances"))?;
856 drop(conn);
857
858 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
859 Some(n) => n,
860 None => return Err(Redirect::to("/instances")),
861 };
862
863 let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name)
864 {
865 Some(d) => d,
866 None => return Err(Redirect::to("/instances")),
867 };
868 let _ = std::fs::create_dir_all(&config_dir);
869
870 // Walk the config directory for files
871 let mut files: Vec<String> = Vec::new();
872 if let Ok(entries) = walk_dir(&config_dir, "") {
873 files = entries;
874 files.sort();
875 }
876
877 // If a file is selected, validate and read it
878 let (selected_file, content, error) = if let Some(raw_path) = &query.path {
879 let cleaned = raw_path.trim_start_matches('/');
880 let target = config_dir.join(cleaned);
881 if !sanitize::path_is_within_base(&target, &config_dir) {
882 (Some(cleaned.to_string()), None, Some("Invalid path".into()))
883 } else if !target.exists() || !target.is_file() {
884 (Some(cleaned.to_string()), None, Some("File not found".into()))
885 } else {
886 match std::fs::read_to_string(&target) {
887 Ok(c) => (Some(cleaned.to_string()), Some(c), None),
888 Err(e) => (Some(cleaned.to_string()), None, Some(format!("Read error: {e}"))),
889 }
890 }
891 } else {
892 (None, None, None)
893 };
894
895 let template = ConfigEditorTemplate {
896 username: account.username.clone(),
897 is_superuser: account.is_superuser,
898 password_change_required: account.password_change_required,
899 instance_id,
900 instance_name,
901 files,
902 selected_file,
903 content,
904 error,
905 saved: false,
906 };
907
908 match template.render() {
909 Ok(html) => Ok(Html(html)),
910 Err(e) => {
911 tracing::error!("Failed to render config template: {e}");
912 Err(Redirect::to(&format!("/instances/{instance_id}/edit")))
913 }
914 }
915}
916
917/// Save a config file back to disk.
918async fn save_config(
919 State(state): State<AppState>,
920 Path(instance_id): Path<i64>,
921 CurrentUser(_user): CurrentUser,
922 Form(form): Form<SaveConfigForm>,
923) -> Result<Html<String>, axum::response::Redirect> {
924 let conn = state.db.conn();
925 let instance_name: String = conn
926 .query_row(
927 "SELECT name FROM instances WHERE id = ?1",
928 params![instance_id],
929 |row| row.get(0),
930 )
931 .map_err(|_| Redirect::to("/instances"))?;
932 drop(conn);
933
934 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
935 Some(n) => n,
936 None => return Err(Redirect::to("/instances")),
937 };
938
939 let config_dir = match sanitize::instance_config_dir(&state.config.server_directory, &safe_name)
940 {
941 Some(d) => d,
942 None => return Err(Redirect::to("/instances")),
943 };
944
945 let cleaned = form.path.trim_start_matches('/');
946 let target = config_dir.join(cleaned);
947
948 let (error, saved) = if !sanitize::path_is_within_base(&target, &config_dir) {
949 (Some("Invalid path".into()), false)
950 } else if let Err(e) = std::fs::write(&target, &form.content) {
951 (Some(format!("Write error: {e}")), false)
952 } else {
953 tracing::info!("Saved config file: {}", target.display());
954 (None, true)
955 };
956
957 // Re-read the file list and selected file content
958 let _ = std::fs::create_dir_all(&config_dir);
959 let mut files: Vec<String> = Vec::new();
960 if let Ok(entries) = walk_dir(&config_dir, "") {
961 files = entries;
962 files.sort();
963 }
964
965 let content = if error.is_none() {
966 Some(form.content)
967 } else {
968 None
969 };
970
971 let template = ConfigEditorTemplate {
972 username: String::new(),
973 is_superuser: false,
974 password_change_required: false,
975 instance_id,
976 instance_name,
977 files,
978 selected_file: Some(cleaned.to_string()),
979 content,
980 error,
981 saved,
982 };
983
984 match template.render() {
985 Ok(html) => Ok(Html(html)),
986 Err(e) => {
987 tracing::error!("Failed to render config template after save: {e}");
988 Err(Redirect::to(&format!("/instances/{instance_id}/config?path={cleaned}")))
989 }
990 }
991}
992
993/// Recursively walk a directory and return relative paths of all files.
994fn walk_dir(dir: &std::path::Path, prefix: &str) -> std::io::Result<Vec<String>> {
995 let mut files = Vec::new();
996 for entry in std::fs::read_dir(dir)? {
997 let entry = entry?;
998 let path = entry.path();
999 let name = entry
1000 .file_name()
1001 .to_string_lossy()
1002 .to_string();
1003 let relative = if prefix.is_empty() {
1004 name
1005 } else {
1006 format!("{prefix}/{name}")
1007 };
1008 if path.is_dir() {
1009 files.extend(walk_dir(&path, &relative)?);
1010 } else if path.is_file() {
1011 files.push(relative);
1012 }
1013 }
1014 Ok(files)
1015}
1016
763async fn regenerate_world( 1017async fn regenerate_world(
764 State(state): State<AppState>, 1018 State(state): State<AppState>,
765 Path(id): Path<i64>, 1019 Path(id): Path<i64>,