summaryrefslogtreecommitdiffstats
path: root/src/routes/instances.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/routes/instances.rs484
1 files changed, 484 insertions, 0 deletions
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}