summaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/routes/accounts.rs236
-rw-r--r--src/routes/backups.rs256
-rw-r--r--src/routes/dashboard.rs68
-rw-r--r--src/routes/instances.rs484
-rw-r--r--src/routes/mod.rs75
-rw-r--r--src/routes/players.rs131
6 files changed, 1250 insertions, 0 deletions
diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs
new file mode 100644
index 0000000..89774f8
--- /dev/null
+++ b/src/routes/accounts.rs
@@ -0,0 +1,236 @@
1use askama::Template;
2use axum::{
3 Form, Router,
4 extract::State,
5 response::{Html, Redirect},
6 routing::{get, post},
7};
8use rusqlite::params;
9use serde::Deserialize;
10
11use crate::auth::{CurrentUser, hash_password};
12use crate::routes::AppState;
13
14#[derive(Template)]
15#[template(path = "accounts/manage.html")]
16struct AccountManagementTemplate {
17 username: String,
18 is_superuser: bool,
19 password_change_required: bool,
20 accounts: Vec<AccountRow>,
21 message: Option<String>,
22}
23
24struct AccountRow {
25 id: i64,
26 username: String,
27 is_superuser: bool,
28}
29
30#[derive(Debug, Deserialize)]
31pub struct CreateAccountForm {
32 pub username: String,
33 pub password: String,
34 pub is_superuser: Option<String>,
35}
36
37#[derive(Debug, Deserialize)]
38pub struct DeleteAccountForm {
39 pub id: i64,
40}
41
42#[derive(Debug, Deserialize)]
43pub struct ChangePasswordForm {
44 pub current_password: String,
45 pub new_password: String,
46}
47
48#[derive(Debug, Deserialize)]
49pub struct SetPasswordForm {
50 pub id: i64,
51 pub new_password: String,
52}
53
54pub fn router() -> Router<AppState> {
55 Router::new()
56 .route("/accounts", get(manage_accounts))
57 .route("/accounts/create", post(create_account))
58 .route("/accounts/delete", post(delete_account))
59 .route("/accounts/set-password", post(set_password))
60 .route(
61 "/accounts/change-own-password",
62 post(change_own_password_from_accounts),
63 )
64}
65
66pub async fn change_password(
67 State(state): State<AppState>,
68 CurrentUser(account): CurrentUser,
69 Form(form): Form<ChangePasswordForm>,
70) -> Result<Redirect, Redirect> {
71 let conn = state.db.conn();
72
73 let hash: String = conn
74 .query_row(
75 "SELECT password FROM accounts WHERE id = ?1",
76 params![account.id],
77 |row| row.get(0),
78 )
79 .map_err(|_| Redirect::to("/login"))?;
80
81 if !crate::auth::verify_password(&form.current_password, &hash).unwrap_or(false) {
82 drop(conn);
83 return Ok(Redirect::to("/change-password"));
84 }
85
86 let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/login"))?;
87
88 conn.execute(
89 "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2",
90 params![new_hash, account.id],
91 )
92 .map_err(|_| Redirect::to("/login"))?;
93
94 conn.execute(
95 "DELETE FROM sessions WHERE account_id = ?1",
96 params![account.id],
97 )
98 .ok();
99 drop(conn);
100
101 Ok(Redirect::to("/login"))
102}
103
104async fn manage_accounts(
105 State(state): State<AppState>,
106 CurrentUser(account): CurrentUser,
107) -> Result<Html<String>, Redirect> {
108 if !account.is_superuser {
109 return Err(Redirect::to("/"));
110 }
111
112 let conn = state.db.conn();
113 let mut stmt = conn
114 .prepare("SELECT id, username, is_superuser FROM accounts ORDER BY username")
115 .map_err(|_| Redirect::to("/login"))?;
116
117 let accounts: Vec<AccountRow> = stmt
118 .query_map([], |row| {
119 Ok(AccountRow {
120 id: row.get(0)?,
121 username: row.get(1)?,
122 is_superuser: row.get::<_, i32>(2)? != 0,
123 })
124 })
125 .map_err(|_| Redirect::to("/login"))?
126 .filter_map(|r| r.ok())
127 .collect();
128 drop(stmt);
129 drop(conn);
130
131 let template = AccountManagementTemplate {
132 username: account.username,
133 is_superuser: account.is_superuser,
134 password_change_required: account.password_change_required,
135 accounts,
136 message: None,
137 };
138
139 Ok(Html(template.render().unwrap_or_default()))
140}
141
142async fn create_account(
143 State(state): State<AppState>,
144 CurrentUser(account): CurrentUser,
145 Form(form): Form<CreateAccountForm>,
146) -> Result<Redirect, Redirect> {
147 if !account.is_superuser {
148 return Ok(Redirect::to("/"));
149 }
150
151 let hash = hash_password(&form.password).map_err(|_| Redirect::to("/accounts"))?;
152 let is_superuser = form.is_superuser.as_deref() == Some("on");
153
154 let conn = state.db.conn();
155 let _ = conn.execute(
156 "INSERT INTO accounts (username, password, is_superuser) VALUES (?1, ?2, ?3)",
157 params![form.username, hash, is_superuser],
158 );
159 drop(conn);
160
161 Ok(Redirect::to("/accounts"))
162}
163
164async fn delete_account(
165 State(state): State<AppState>,
166 CurrentUser(account): CurrentUser,
167 Form(form): Form<DeleteAccountForm>,
168) -> Result<Redirect, Redirect> {
169 if !account.is_superuser || account.id == form.id {
170 return Ok(Redirect::to("/accounts"));
171 }
172
173 let conn = state.db.conn();
174 let superuser_count: i32 = conn
175 .query_row(
176 "SELECT COUNT(*) FROM accounts WHERE is_superuser = 1 AND id != ?1",
177 params![form.id],
178 |row| row.get(0),
179 )
180 .unwrap_or(0);
181
182 let is_superuser: bool = conn
183 .query_row(
184 "SELECT is_superuser FROM accounts WHERE id = ?1",
185 params![form.id],
186 |row| row.get::<_, i32>(0).map(|v| v != 0),
187 )
188 .unwrap_or(false);
189
190 if is_superuser && superuser_count == 0 {
191 drop(conn);
192 return Ok(Redirect::to("/accounts"));
193 }
194
195 let _ = conn.execute("DELETE FROM accounts WHERE id = ?1", params![form.id]);
196 drop(conn);
197
198 Ok(Redirect::to("/accounts"))
199}
200
201async fn set_password(
202 State(state): State<AppState>,
203 CurrentUser(account): CurrentUser,
204 Form(form): Form<SetPasswordForm>,
205) -> Result<Redirect, Redirect> {
206 if !account.is_superuser {
207 return Ok(Redirect::to("/"));
208 }
209
210 let new_hash = hash_password(&form.new_password).map_err(|_| Redirect::to("/accounts"))?;
211
212 let conn = state.db.conn();
213 conn.execute(
214 "UPDATE accounts SET password = ?1, password_change_required = 0 WHERE id = ?2",
215 params![new_hash, form.id],
216 )
217 .ok();
218
219 // Invalidate sessions for the target account
220 conn.execute(
221 "DELETE FROM sessions WHERE account_id = ?1",
222 params![form.id],
223 )
224 .ok();
225 drop(conn);
226
227 Ok(Redirect::to("/accounts"))
228}
229
230async fn change_own_password_from_accounts(
231 State(state): State<AppState>,
232 CurrentUser(account): CurrentUser,
233 Form(form): Form<ChangePasswordForm>,
234) -> Result<Redirect, Redirect> {
235 change_password(State(state), CurrentUser(account), Form(form)).await
236}
diff --git a/src/routes/backups.rs b/src/routes/backups.rs
new file mode 100644
index 0000000..09298d2
--- /dev/null
+++ b/src/routes/backups.rs
@@ -0,0 +1,256 @@
1use askama::Template;
2use axum::{
3 Router,
4 body::Body,
5 extract::{Path, State},
6 http::{StatusCode, header},
7 response::{Html, Redirect, Response},
8 routing::{get, post},
9};
10use rusqlite::params;
11use zip::write::FileOptions;
12
13use crate::auth::CurrentUser;
14use crate::routes::AppState;
15
16#[derive(Template)]
17#[template(path = "backups/list.html")]
18struct BackupListTemplate {
19 username: String,
20 is_superuser: bool,
21 password_change_required: bool,
22 instance_name: String,
23 instance_id: i64,
24 backups: Vec<BackupRow>,
25 message: Option<String>,
26}
27
28struct BackupRow {
29 id: i64,
30 filename: String,
31 created_at: String,
32}
33
34pub fn router() -> Router<AppState> {
35 Router::new()
36 .route("/instances/{id}/backups", get(list_backups))
37 .route("/instances/{id}/backups/create", post(create_backup))
38 .route("/backups/{id}/restore", post(restore_backup))
39 .route("/backups/{id}/download", get(download_backup))
40 .route("/instances/{id}/world/export", get(export_world))
41}
42
43async fn list_backups(
44 State(state): State<AppState>,
45 Path(instance_id): Path<i64>,
46 CurrentUser(account): CurrentUser,
47) -> Result<Html<String>, axum::response::Redirect> {
48 let conn = state.db.conn();
49 let instance_name: String = conn
50 .query_row(
51 "SELECT name FROM instances WHERE id = ?1",
52 params![instance_id],
53 |row| row.get(0),
54 )
55 .map_err(|_| Redirect::to("/instances"))?;
56
57 let mut stmt = conn
58 .prepare("SELECT id, filename, created_at FROM backups WHERE instance_id = ?1 ORDER BY created_at DESC")
59 .map_err(|_| Redirect::to("/instances"))?;
60
61 let backups: Vec<BackupRow> = stmt
62 .query_map(params![instance_id], |row| {
63 Ok(BackupRow {
64 id: row.get(0)?,
65 filename: row.get(1)?,
66 created_at: row.get(2)?,
67 })
68 })
69 .map_err(|_| Redirect::to("/instances"))?
70 .filter_map(|r| r.ok())
71 .collect();
72 drop(stmt);
73 drop(conn);
74
75 let template = BackupListTemplate {
76 username: account.username.clone(),
77 is_superuser: account.is_superuser,
78 password_change_required: account.password_change_required,
79 instance_name,
80 instance_id,
81 backups,
82 message: None,
83 };
84
85 Ok(Html(template.render().unwrap_or_default()))
86}
87
88async fn create_backup(
89 State(state): State<AppState>,
90 Path(instance_id): Path<i64>,
91 CurrentUser(_user): CurrentUser,
92) -> Result<Redirect, axum::response::Redirect> {
93 let conn = state.db.conn();
94 let instance_name: String = conn
95 .query_row(
96 "SELECT name FROM instances WHERE id = ?1",
97 params![instance_id],
98 |row| row.get(0),
99 )
100 .map_err(|_| Redirect::to("/instances"))?;
101 drop(conn);
102
103 let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
104 let filename = format!("{instance_name}_{timestamp}.zip");
105
106 let data_dir = state
107 .config
108 .server_directory
109 .join("instances")
110 .join(&instance_name)
111 .join("data");
112
113 let backup_dir = state.config.server_directory.join("backups");
114 let _ = std::fs::create_dir_all(&backup_dir);
115 let backup_path = backup_dir.join(&filename);
116
117 if let Ok(file) = std::fs::File::create(&backup_path) {
118 let mut zip = zip::ZipWriter::new(file);
119 let options: FileOptions<'_, ()> =
120 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
121
122 if let Ok(entries) = std::fs::read_dir(&data_dir) {
123 for entry in entries.flatten() {
124 let path = entry.path();
125 if path.is_file() {
126 if let Some(name) = path.file_name() {
127 if let Ok(contents) = std::fs::read(&path) {
128 let _ = zip.start_file(&*name.to_string_lossy(), options);
129 let _ = std::io::Write::write_all(&mut zip, &contents);
130 }
131 }
132 }
133 }
134 }
135 let _ = zip.finish();
136 }
137
138 let conn = state.db.conn();
139 conn.execute(
140 "INSERT INTO backups (instance_id, filename) VALUES (?1, ?2)",
141 params![instance_id, filename],
142 )
143 .ok();
144 drop(conn);
145
146 Ok(Redirect::to(&format!("/instances/{instance_id}/backups")))
147}
148
149async fn restore_backup(
150 State(state): State<AppState>,
151 Path(backup_id): Path<i64>,
152 CurrentUser(_user): CurrentUser,
153) -> Result<Redirect, axum::response::Redirect> {
154 let conn = state.db.conn();
155 let (instance_id, _filename): (i64, String) = conn
156 .query_row(
157 "SELECT instance_id, filename FROM backups WHERE id = ?1",
158 params![backup_id],
159 |row| Ok((row.get(0)?, row.get(1)?)),
160 )
161 .map_err(|_| Redirect::to("/instances"))?;
162 drop(conn);
163
164 Ok(Redirect::to(&format!("/instances/{instance_id}/backups")))
165}
166
167async fn download_backup(
168 State(state): State<AppState>,
169 Path(backup_id): Path<i64>,
170 CurrentUser(_user): CurrentUser,
171) -> Result<Response, (StatusCode, &'static str)> {
172 let conn = state.db.conn();
173 let (_, filename): (i64, String) = conn
174 .query_row(
175 "SELECT instance_id, filename FROM backups WHERE id = ?1",
176 params![backup_id],
177 |row| Ok((row.get(0)?, row.get(1)?)),
178 )
179 .map_err(|_| (StatusCode::NOT_FOUND, "Backup not found"))?;
180 drop(conn);
181
182 let backup_path = state
183 .config
184 .server_directory
185 .join("backups")
186 .join(&filename);
187
188 let content =
189 std::fs::read(&backup_path).map_err(|_| (StatusCode::NOT_FOUND, "File not found"))?;
190
191 Ok(Response::builder()
192 .header(header::CONTENT_TYPE, "application/zip")
193 .header(
194 header::CONTENT_DISPOSITION,
195 format!("attachment; filename=\"{filename}\""),
196 )
197 .body(Body::from(content))
198 .unwrap())
199}
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/dashboard.rs b/src/routes/dashboard.rs
new file mode 100644
index 0000000..7d5d3e4
--- /dev/null
+++ b/src/routes/dashboard.rs
@@ -0,0 +1,68 @@
1use askama::Template;
2use axum::{
3 Router,
4 extract::State,
5 response::{Html, IntoResponse},
6 routing::get,
7};
8
9use crate::auth::CurrentUser;
10use crate::routes::AppState;
11
12#[derive(Template)]
13#[template(path = "dashboard.html")]
14struct DashboardTemplate {
15 username: String,
16 is_superuser: bool,
17 password_change_required: bool,
18 active_instance: Option<ActiveInstanceInfo>,
19 container_state: String,
20}
21
22#[derive(Clone)]
23struct ActiveInstanceInfo {
24 name: String,
25 version: String,
26 server_type: String,
27 max_players: i32,
28}
29
30pub fn router() -> Router<AppState> {
31 Router::new().route("/", get(dashboard))
32}
33
34async fn dashboard(
35 State(state): State<AppState>,
36 CurrentUser(account): CurrentUser,
37) -> Result<impl IntoResponse, axum::response::Redirect> {
38 let (active_info, container_state) = {
39 let conn = state.db.conn();
40 let result = conn.query_row(
41 "SELECT name, version, server_type, max_players, container_state
42 FROM instances WHERE is_active = 1",
43 [],
44 |row| {
45 Ok((
46 Some(ActiveInstanceInfo {
47 name: row.get(0)?,
48 version: row.get(1)?,
49 server_type: row.get(2)?,
50 max_players: row.get(3)?,
51 }),
52 row.get::<_, String>(4)?,
53 ))
54 },
55 );
56 result.unwrap_or((None, "stopped".into()))
57 };
58
59 let template = DashboardTemplate {
60 username: account.username,
61 is_superuser: account.is_superuser,
62 password_change_required: account.password_change_required,
63 active_instance: active_info,
64 container_state,
65 };
66
67 Ok(Html(template.render().unwrap_or_default()))
68}
diff --git a/src/routes/instances.rs b/src/routes/instances.rs
new file mode 100644
index 0000000..2da1319
--- /dev/null
+++ b/src/routes/instances.rs
@@ -0,0 +1,484 @@
1use askama::Template;
2use axum::{
3 Form, Router,
4 extract::{Path, State},
5 response::{Html, Redirect},
6 routing::{get, post},
7};
8use rusqlite::params;
9use serde::Deserialize;
10
11use crate::auth::CurrentUser;
12use crate::models::Instance;
13use crate::routes::AppState;
14
15#[derive(Template)]
16#[template(path = "instances/list.html")]
17struct InstanceListTemplate {
18 username: String,
19 is_superuser: bool,
20 password_change_required: bool,
21 instances: Vec<InstanceInfo>,
22 has_active: bool,
23}
24
25#[derive(Template)]
26#[template(path = "instances/create.html")]
27struct CreateInstanceTemplate {
28 username: String,
29 is_superuser: bool,
30 password_change_required: bool,
31 versions: Vec<String>,
32 max_memory_mb: u32,
33 error: Option<String>,
34}
35
36#[derive(Template)]
37#[template(path = "instances/edit.html")]
38struct EditInstanceTemplate {
39 username: String,
40 is_superuser: bool,
41 password_change_required: bool,
42 instance: Instance,
43 max_memory_mb: u32,
44 error: Option<String>,
45}
46
47struct InstanceInfo {
48 id: i64,
49 name: String,
50 version: String,
51 server_type: String,
52 is_active: bool,
53 has_modpack: bool,
54}
55
56#[derive(Debug, Deserialize)]
57pub struct CreateInstanceForm {
58 pub name: String,
59 pub version: String,
60 pub server_type: String,
61 pub world_source: String,
62 pub seed: Option<String>,
63 pub level_type: Option<String>,
64 pub memory_mb: i32,
65 pub difficulty: i32,
66 pub gamemode: i32,
67 pub max_players: i32,
68 pub pvp: Option<String>,
69 pub motd: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
73pub struct EditInstanceForm {
74 pub difficulty: i32,
75 pub gamemode: i32,
76 pub max_players: i32,
77 pub pvp: Option<String>,
78 pub motd: Option<String>,
79 pub memory_mb: i32,
80}
81
82pub fn router() -> Router<AppState> {
83 Router::new()
84 .route("/instances", get(list_instances))
85 .route("/instances/new", get(create_form))
86 .route("/instances/new", post(create_instance))
87 .route("/instances/{id}/edit", get(edit_form))
88 .route("/instances/{id}/edit", post(edit_instance))
89 .route("/instances/{id}/delete", post(delete_instance))
90 .route("/instances/{id}/activate", post(activate_instance))
91 .route("/instances/deactivate", post(deactivate_instance))
92}
93
94async fn list_instances(
95 State(state): State<AppState>,
96 CurrentUser(account): CurrentUser,
97) -> Result<Html<String>, axum::response::Redirect> {
98 let conn = state.db.conn();
99 let mut stmt = conn
100 .prepare("SELECT id, name, version, server_type, is_active, has_modpack FROM instances ORDER BY name")
101 .map_err(|_| Redirect::to("/login"))?;
102
103 let instances: Vec<InstanceInfo> = stmt
104 .query_map([], |row| {
105 Ok(InstanceInfo {
106 id: row.get(0)?,
107 name: row.get(1)?,
108 version: row.get(2)?,
109 server_type: row.get(3)?,
110 is_active: row.get::<_, i32>(4)? != 0,
111 has_modpack: row.get::<_, i32>(5)? != 0,
112 })
113 })
114 .map_err(|_| Redirect::to("/login"))?
115 .filter_map(|r| r.ok())
116 .collect();
117
118 let has_active = instances.iter().any(|i| i.is_active);
119 drop(stmt);
120 drop(conn);
121
122 let template = InstanceListTemplate {
123 username: account.username.clone(),
124 is_superuser: account.is_superuser,
125 password_change_required: account.password_change_required,
126 instances,
127 has_active,
128 };
129
130 Ok(Html(template.render().unwrap_or_default()))
131}
132
133async fn create_form(
134 State(state): State<AppState>,
135 CurrentUser(account): CurrentUser,
136) -> Result<Html<String>, axum::response::Redirect> {
137 let versions = fetch_minecraft_versions().await.unwrap_or_else(|_| {
138 vec![
139 "1.21".into(),
140 "1.20.6".into(),
141 "1.20.1".into(),
142 "1.19.4".into(),
143 "1.12.2".into(),
144 ]
145 });
146
147 let template = CreateInstanceTemplate {
148 username: account.username.clone(),
149 is_superuser: account.is_superuser,
150 password_change_required: account.password_change_required,
151 versions,
152 max_memory_mb: state.config.max_memory_mb,
153 error: None,
154 };
155
156 Ok(Html(template.render().unwrap_or_default()))
157}
158
159async fn create_instance(
160 State(state): State<AppState>,
161 CurrentUser(_user): CurrentUser,
162 Form(form): Form<CreateInstanceForm>,
163) -> Result<Redirect, axum::response::Redirect> {
164 if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 {
165 return Ok(Redirect::to("/instances/new"));
166 }
167
168 let rcon_password = generate_rcon_password();
169 let pvp = form.pvp.as_deref() == Some("on");
170 let motd = form.motd.unwrap_or_else(|| "A Minecraft Server".into());
171 let level_type = form.level_type.unwrap_or_else(|| "DEFAULT".into());
172
173 let conn = state.db.conn();
174 conn.execute(
175 "INSERT INTO instances (name, version, server_type, world_source, world_seed, level_type,
176 memory_mb, difficulty, gamemode, max_players, pvp, motd, rcon_password, has_modpack)
177 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0)",
178 params![
179 form.name,
180 form.version,
181 form.server_type,
182 form.world_source,
183 form.seed,
184 level_type,
185 form.memory_mb,
186 form.difficulty,
187 form.gamemode,
188 form.max_players,
189 pvp,
190 motd,
191 rcon_password,
192 ],
193 )
194 .ok();
195 drop(conn);
196
197 let instance_dir = state
198 .config
199 .server_directory
200 .join("instances")
201 .join(&form.name)
202 .join("data");
203 let _ = std::fs::create_dir_all(&instance_dir);
204
205 Ok(Redirect::to("/instances"))
206}
207
208async fn edit_form(
209 State(state): State<AppState>,
210 Path(id): Path<i64>,
211 CurrentUser(account): CurrentUser,
212) -> Result<Html<String>, axum::response::Redirect> {
213 let conn = state.db.conn();
214 let instance = conn
215 .query_row(
216 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
217 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd,
218 rcon_password, is_active, created_at, updated_at
219 FROM instances WHERE id = ?1",
220 params![id],
221 |row| {
222 Ok(Instance {
223 id: row.get(0)?,
224 name: row.get(1)?,
225 version: row.get(2)?,
226 server_type: row.get(3)?,
227 world_source: row.get(4)?,
228 world_seed: row.get(5)?,
229 level_type: row.get(6)?,
230 has_modpack: row.get::<_, i32>(7)? != 0,
231 memory_mb: row.get(8)?,
232 difficulty: row.get(9)?,
233 gamemode: row.get(10)?,
234 max_players: row.get(11)?,
235 pvp: row.get::<_, i32>(12)? != 0,
236 motd: row.get(13)?,
237 rcon_password: row.get(14)?,
238 is_active: row.get::<_, i32>(15)? != 0,
239 created_at: row.get(16)?,
240 updated_at: row.get(17)?,
241 })
242 },
243 )
244 .map_err(|_| Redirect::to("/instances"))?;
245 drop(conn);
246
247 let template = EditInstanceTemplate {
248 username: account.username.clone(),
249 is_superuser: account.is_superuser,
250 password_change_required: account.password_change_required,
251 instance,
252 max_memory_mb: state.config.max_memory_mb,
253 error: None,
254 };
255
256 Ok(Html(template.render().unwrap_or_default()))
257}
258
259#[axum::debug_handler]
260async fn edit_instance(
261 State(state): State<AppState>,
262 Path(id): Path<i64>,
263 CurrentUser(_user): CurrentUser,
264 Form(form): Form<EditInstanceForm>,
265) -> Result<Redirect, axum::response::Redirect> {
266 if form.memory_mb > state.config.max_memory_mb as i32 || form.memory_mb < 512 {
267 return Ok(Redirect::to(&format!("/instances/{id}/edit")));
268 }
269
270 let pvp = form.pvp.as_deref() == Some("on");
271
272 let is_active = {
273 let conn = state.db.conn();
274 conn.execute(
275 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3,
276 pvp = ?4, motd = ?5, memory_mb = ?6, updated_at = datetime('now')
277 WHERE id = ?7",
278 params![
279 form.difficulty,
280 form.gamemode,
281 form.max_players,
282 pvp,
283 form.motd,
284 form.memory_mb,
285 id,
286 ],
287 )
288 .map_err(|_| Redirect::to("/instances"))?;
289
290 conn.query_row(
291 "SELECT is_active FROM instances WHERE id = ?1",
292 params![id],
293 |row| row.get::<_, i32>(0).map(|v| v != 0),
294 )
295 .unwrap_or(false)
296 };
297
298 if is_active {
299 let _ = state.docker.stop_container().await;
300 }
301
302 Ok(Redirect::to("/instances"))
303}
304
305async fn delete_instance(
306 State(state): State<AppState>,
307 Path(id): Path<i64>,
308 CurrentUser(_user): CurrentUser,
309) -> Result<Redirect, axum::response::Redirect> {
310 let conn = state.db.conn();
311 let is_active: bool = conn
312 .query_row(
313 "SELECT is_active FROM instances WHERE id = ?1",
314 params![id],
315 |row| row.get::<_, i32>(0).map(|v| v != 0),
316 )
317 .unwrap_or(false);
318
319 if is_active {
320 drop(conn);
321 return Ok(Redirect::to("/instances"));
322 }
323
324 let name: Option<String> = conn
325 .query_row(
326 "SELECT name FROM instances WHERE id = ?1",
327 params![id],
328 |row| row.get(0),
329 )
330 .ok();
331
332 conn.execute("DELETE FROM instances WHERE id = ?1", params![id])
333 .ok();
334 drop(conn);
335
336 if let Some(name) = name {
337 let instance_dir = state.config.server_directory.join("instances").join(&name);
338 let _ = std::fs::remove_dir_all(&instance_dir);
339 }
340
341 Ok(Redirect::to("/instances"))
342}
343
344#[axum::debug_handler]
345async fn activate_instance(
346 State(state): State<AppState>,
347 Path(id): Path<i64>,
348 CurrentUser(_user): CurrentUser,
349) -> Result<Redirect, axum::response::Redirect> {
350 // Stop any running container first (this is usually fast)
351 let _ = state.docker.stop_container().await;
352
353 // Update DB: deactivate all, activate the new one
354 let instance = {
355 let conn = state.db.conn();
356 conn.execute("UPDATE instances SET is_active = 0 WHERE is_active = 1", [])
357 .ok();
358 conn.execute(
359 "UPDATE instances SET is_active = 1, updated_at = datetime('now') WHERE id = ?1",
360 params![id],
361 )
362 .ok();
363
364 conn.query_row(
365 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
366 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd,
367 rcon_password, is_active, created_at, updated_at
368 FROM instances WHERE id = ?1",
369 params![id],
370 |row| {
371 Ok(Instance {
372 id: row.get(0)?,
373 name: row.get(1)?,
374 version: row.get(2)?,
375 server_type: row.get(3)?,
376 world_source: row.get(4)?,
377 world_seed: row.get(5)?,
378 level_type: row.get(6)?,
379 has_modpack: row.get::<_, i32>(7)? != 0,
380 memory_mb: row.get(8)?,
381 difficulty: row.get(9)?,
382 gamemode: row.get(10)?,
383 max_players: row.get(11)?,
384 pvp: row.get::<_, i32>(12)? != 0,
385 motd: row.get(13)?,
386 rcon_password: row.get(14)?,
387 is_active: row.get::<_, i32>(15)? != 0,
388 created_at: row.get(16)?,
389 updated_at: row.get(17)?,
390 })
391 },
392 )
393 .map_err(|_| Redirect::to("/instances"))?
394 };
395
396 // Pull image + start container in background so the UI stays responsive
397 let docker = state.docker.clone();
398 let db = state.db.clone();
399 tokio::spawn(async move {
400 {
401 let conn = db.conn();
402 conn.execute(
403 "UPDATE instances SET container_state = 'pulling' WHERE is_active = 1",
404 [],
405 )
406 .ok();
407 }
408 match docker.start_container(&instance).await {
409 Ok(()) => {
410 let conn = db.conn();
411 conn.execute(
412 "UPDATE instances SET container_state = 'running' WHERE is_active = 1",
413 [],
414 )
415 .ok();
416 }
417 Err(e) => {
418 tracing::error!("Failed to start container: {e}");
419 let conn = db.conn();
420 conn.execute(
421 "UPDATE instances SET container_state = 'error' WHERE is_active = 1",
422 [],
423 )
424 .ok();
425 }
426 }
427 });
428
429 Ok(Redirect::to("/"))
430}
431
432async fn deactivate_instance(
433 State(state): State<AppState>,
434 CurrentUser(_user): CurrentUser,
435) -> Result<Redirect, axum::response::Redirect> {
436 let _ = state.docker.stop_container().await;
437
438 let conn = state.db.conn();
439 conn.execute(
440 "UPDATE instances SET is_active = 0, container_state = 'stopped' WHERE is_active = 1",
441 [],
442 )
443 .ok();
444 drop(conn);
445
446 Ok(Redirect::to("/"))
447}
448
449fn generate_rcon_password() -> String {
450 use rand::Rng;
451 let mut rng = rand::thread_rng();
452 let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz0123456789".chars().collect();
453 (0..16)
454 .map(|_| chars[rng.gen_range(0..chars.len())])
455 .collect()
456}
457
458async fn fetch_minecraft_versions() -> Result<Vec<String>, String> {
459 let client = reqwest::Client::new();
460 let manifest: serde_json::Value = client
461 .get("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")
462 .send()
463 .await
464 .map_err(|e| e.to_string())?
465 .json()
466 .await
467 .map_err(|e| e.to_string())?;
468
469 let versions: Vec<String> = manifest["versions"]
470 .as_array()
471 .ok_or("Invalid version manifest")?
472 .iter()
473 .filter_map(|v| {
474 let t = v["type"].as_str()?;
475 if t == "release" {
476 v["id"].as_str().map(String::from)
477 } else {
478 None
479 }
480 })
481 .collect();
482
483 Ok(versions)
484}
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
new file mode 100644
index 0000000..1cb0e54
--- /dev/null
+++ b/src/routes/mod.rs
@@ -0,0 +1,75 @@
1pub mod accounts;
2pub mod backups;
3pub mod dashboard;
4pub mod instances;
5pub mod players;
6
7use askama::Template;
8use axum::{Router, response::Html};
9use tower_http::services::ServeDir;
10
11use crate::auth::CurrentUser;
12use crate::config::Config;
13use crate::db::Database;
14use crate::docker::DockerManager;
15
16#[derive(Clone)]
17pub struct AppState {
18 pub config: Config,
19 pub db: Database,
20 pub docker: DockerManager,
21}
22
23pub fn build_router(state: AppState) -> Router {
24 let auth_required =
25 axum::middleware::from_fn_with_state(state.clone(), crate::auth::require_auth);
26
27 let protected = Router::new()
28 .route("/change-password", axum::routing::get(change_password_page))
29 .route(
30 "/change-password",
31 axum::routing::post(accounts::change_password),
32 )
33 .merge(dashboard::router())
34 .merge(instances::router())
35 .merge(players::router())
36 .merge(accounts::router())
37 .merge(backups::router())
38 .layer(auth_required);
39
40 Router::new()
41 .route("/login", axum::routing::get(login_page))
42 .route("/login", axum::routing::post(crate::auth::authenticate))
43 .route("/logout", axum::routing::post(crate::auth::logout))
44 .merge(protected)
45 .nest_service("/static", ServeDir::new("static"))
46 .with_state(state)
47}
48
49#[derive(Template)]
50#[template(path = "login.html")]
51struct LoginPageTemplate;
52
53async fn login_page() -> Html<String> {
54 let template = LoginPageTemplate;
55 Html(template.render().unwrap_or_default())
56}
57
58#[derive(Template)]
59#[template(path = "change_password.html")]
60struct ChangePasswordPageTemplate {
61 username: String,
62 is_superuser: bool,
63 password_change_required: bool,
64 error: String,
65}
66
67async fn change_password_page(CurrentUser(account): CurrentUser) -> Html<String> {
68 let template = ChangePasswordPageTemplate {
69 username: account.username,
70 is_superuser: account.is_superuser,
71 password_change_required: account.password_change_required,
72 error: String::new(),
73 };
74 Html(template.render().unwrap_or_default())
75}
diff --git a/src/routes/players.rs b/src/routes/players.rs
new file mode 100644
index 0000000..b98a39d
--- /dev/null
+++ b/src/routes/players.rs
@@ -0,0 +1,131 @@
1use askama::Template;
2use axum::{
3 Form, Router,
4 extract::State,
5 response::{Html, Redirect},
6 routing::{get, post},
7};
8use serde::Deserialize;
9
10use crate::auth::CurrentUser;
11use crate::routes::AppState;
12
13#[derive(Template)]
14#[template(path = "players/manage.html")]
15struct PlayerManagementTemplate {
16 username: String,
17 is_superuser: bool,
18 password_change_required: bool,
19 active_instance_name: Option<String>,
20 whitelist: Vec<String>,
21 ops: Vec<String>,
22 bans: Vec<String>,
23 message: Option<String>,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct PlayerAction {
28 pub username: String,
29}
30
31pub fn router() -> Router<AppState> {
32 Router::new()
33 .route("/players", get(manage_players))
34 .route("/players/whitelist/add", post(add_whitelist))
35 .route("/players/whitelist/remove", post(remove_whitelist))
36 .route("/players/op/add", post(add_op))
37 .route("/players/op/remove", post(remove_op))
38 .route("/players/ban/add", post(add_ban))
39 .route("/players/ban/remove", post(remove_ban))
40}
41
42async fn manage_players(
43 State(state): State<AppState>,
44 CurrentUser(account): CurrentUser,
45) -> Result<Html<String>, axum::response::Redirect> {
46 let active = get_active_instance(&state.db);
47
48 let (instance_name, whitelist, ops, bans) =
49 if let Some((_id, inst_name, _rcon_pw, _container_state)) = active {
50 let data_dir = state
51 .config
52 .server_directory
53 .join("instances")
54 .join(&inst_name)
55 .join("data");
56
57 let w = read_json_list(&data_dir.join("whitelist.json"));
58 let o = read_json_list(&data_dir.join("ops.json"));
59 let b = read_json_list(&data_dir.join("banned-players.json"));
60 (Some(inst_name), w, o, b)
61 } else {
62 (None, vec![], vec![], vec![])
63 };
64
65 let template = PlayerManagementTemplate {
66 username: account.username,
67 is_superuser: account.is_superuser,
68 password_change_required: account.password_change_required,
69 active_instance_name: instance_name,
70 whitelist,
71 ops,
72 bans,
73 message: None,
74 };
75
76 Ok(Html(template.render().unwrap_or_default()))
77}
78
79fn read_json_list(path: &std::path::Path) -> Vec<String> {
80 if let Ok(contents) = std::fs::read_to_string(path) {
81 if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) {
82 return entries
83 .iter()
84 .filter_map(|e| e["name"].as_str().map(String::from))
85 .collect();
86 }
87 }
88 vec![]
89}
90
91macro_rules! player_action {
92 ($name:ident, $command:expr) => {
93 async fn $name(
94 State(state): State<AppState>,
95 CurrentUser(_user): CurrentUser,
96 Form(form): Form<PlayerAction>,
97 ) -> Result<Redirect, axum::response::Redirect> {
98 if let Some((_, _, rcon_pw, _)) = get_active_instance(&state.db) {
99 let client = crate::rcon::RconClient::new(rcon_pw);
100 let cmd = format!($command, form.username);
101 match client.send_command_async(cmd.clone()).await {
102 Ok(resp) => tracing::info!("RCON '{}' -> {}", cmd, resp.trim()),
103 Err(e) => tracing::error!("RCON '{}' failed: {}", cmd, e),
104 }
105 } else {
106 tracing::warn!(
107 "Player action '{}' called but no active instance",
108 stringify!($name)
109 );
110 }
111 Ok(Redirect::to("/players"))
112 }
113 };
114}
115
116player_action!(add_whitelist, "whitelist add {}");
117player_action!(remove_whitelist, "whitelist remove {}");
118player_action!(add_op, "op {}");
119player_action!(remove_op, "deop {}");
120player_action!(add_ban, "ban {}");
121player_action!(remove_ban, "pardon {}");
122
123fn get_active_instance(db: &crate::db::Database) -> Option<(i64, String, String, String)> {
124 db.conn()
125 .query_row(
126 "SELECT id, name, rcon_password, container_state FROM instances WHERE is_active = 1",
127 [],
128 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
129 )
130 .ok()
131}