summaryrefslogtreecommitdiffstats
path: root/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'src/routes')
-rw-r--r--src/routes/backups.rs8
-rw-r--r--src/routes/instances.rs61
-rw-r--r--src/routes/mod.rs2
-rw-r--r--src/routes/mods.rs422
-rw-r--r--src/routes/players.rs5
5 files changed, 474 insertions, 24 deletions
diff --git a/src/routes/backups.rs b/src/routes/backups.rs
index 6f7617e..d68be8d 100644
--- a/src/routes/backups.rs
+++ b/src/routes/backups.rs
@@ -119,14 +119,12 @@ async fn create_backup(
119 if let Ok(entries) = std::fs::read_dir(&data_dir) { 119 if let Ok(entries) = std::fs::read_dir(&data_dir) {
120 for entry in entries.flatten() { 120 for entry in entries.flatten() {
121 let path = entry.path(); 121 let path = entry.path();
122 if path.is_file() { 122 if path.is_file()
123 if let Some(name) = path.file_name() { 123 && let Some(name) = path.file_name()
124 if let Ok(contents) = std::fs::read(&path) { 124 && let Ok(contents) = std::fs::read(&path) {
125 let _ = zip.start_file(&*name.to_string_lossy(), options); 125 let _ = zip.start_file(&*name.to_string_lossy(), options);
126 let _ = std::io::Write::write_all(&mut zip, &contents); 126 let _ = std::io::Write::write_all(&mut zip, &contents);
127 } 127 }
128 }
129 }
130 } 128 }
131 } 129 }
132 let _ = zip.finish(); 130 let _ = zip.finish();
diff --git a/src/routes/instances.rs b/src/routes/instances.rs
index 8dd6a62..f1c3e37 100644
--- a/src/routes/instances.rs
+++ b/src/routes/instances.rs
@@ -41,6 +41,8 @@ struct EditInstanceTemplate {
41 instance: Instance, 41 instance: Instance,
42 current_seed: Option<String>, 42 current_seed: Option<String>,
43 max_memory_mb: u32, 43 max_memory_mb: u32,
44 mod_count: i64,
45 rejected_count: i64,
44} 46}
45 47
46struct InstanceInfo { 48struct InstanceInfo {
@@ -74,6 +76,7 @@ pub struct EditInstanceForm {
74 pub gamemode: i32, 76 pub gamemode: i32,
75 pub max_players: i32, 77 pub max_players: i32,
76 pub pvp: Option<String>, 78 pub pvp: Option<String>,
79 pub whitelist_enabled: Option<String>,
77 pub motd: Option<String>, 80 pub motd: Option<String>,
78 pub memory_mb: i32, 81 pub memory_mb: i32,
79} 82}
@@ -216,8 +219,8 @@ async fn edit_form(
216 let conn = state.db.conn(); 219 let conn = state.db.conn();
217 conn.query_row( 220 conn.query_row(
218 "SELECT id, name, version, server_type, world_source, world_seed, level_type, 221 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
219 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, 222 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, whitelist_enabled,
220 rcon_password, is_active, created_at, updated_at 223 motd, rcon_password, is_active, created_at, updated_at
221 FROM instances WHERE id = ?1", 224 FROM instances WHERE id = ?1",
222 params![id], 225 params![id],
223 |row| { 226 |row| {
@@ -235,11 +238,12 @@ async fn edit_form(
235 gamemode: row.get(10)?, 238 gamemode: row.get(10)?,
236 max_players: row.get(11)?, 239 max_players: row.get(11)?,
237 pvp: row.get::<_, i32>(12)? != 0, 240 pvp: row.get::<_, i32>(12)? != 0,
238 motd: row.get(13)?, 241 whitelist_enabled: row.get::<_, i32>(13)? != 0,
239 rcon_password: row.get(14)?, 242 motd: row.get(14)?,
240 is_active: row.get::<_, i32>(15)? != 0, 243 rcon_password: row.get(15)?,
241 created_at: row.get(16)?, 244 is_active: row.get::<_, i32>(16)? != 0,
242 updated_at: row.get(17)?, 245 created_at: row.get(17)?,
246 updated_at: row.get(18)?,
243 }) 247 })
244 }, 248 },
245 ) 249 )
@@ -272,6 +276,26 @@ async fn edit_form(
272 current_seed 276 current_seed
273 }; 277 };
274 278
279 let (mod_count, rejected_count) = {
280 let conn = state.db.conn();
281 let total: i64 = conn
282 .query_row(
283 "SELECT COUNT(*) FROM mods WHERE instance_id = ?1",
284 params![id],
285 |row| row.get(0),
286 )
287 .unwrap_or(0);
288 let rejected: i64 = conn
289 .query_row(
290 "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 0",
291 params![id],
292 |row| row.get(0),
293 )
294 .unwrap_or(0);
295 drop(conn);
296 (total, rejected)
297 };
298
275 let template = EditInstanceTemplate { 299 let template = EditInstanceTemplate {
276 username: account.username.clone(), 300 username: account.username.clone(),
277 is_superuser: account.is_superuser, 301 is_superuser: account.is_superuser,
@@ -279,6 +303,8 @@ async fn edit_form(
279 instance, 303 instance,
280 current_seed, 304 current_seed,
281 max_memory_mb: state.config.max_memory_mb, 305 max_memory_mb: state.config.max_memory_mb,
306 mod_count,
307 rejected_count,
282 }; 308 };
283 309
284 Ok(Html(template.render().unwrap_or_default())) 310 Ok(Html(template.render().unwrap_or_default()))
@@ -295,18 +321,20 @@ async fn edit_instance(
295 } 321 }
296 322
297 let pvp = form.pvp.as_deref() == Some("on"); 323 let pvp = form.pvp.as_deref() == Some("on");
324 let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on");
298 325
299 let is_active = { 326 let is_active = {
300 let conn = state.db.conn(); 327 let conn = state.db.conn();
301 conn.execute( 328 conn.execute(
302 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, 329 "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3,
303 pvp = ?4, motd = ?5, memory_mb = ?6, updated_at = datetime('now') 330 pvp = ?4, whitelist_enabled = ?5, motd = ?6, memory_mb = ?7,
304 WHERE id = ?7", 331 updated_at = datetime('now') WHERE id = ?8",
305 params![ 332 params![
306 form.difficulty, 333 form.difficulty,
307 form.gamemode, 334 form.gamemode,
308 form.max_players, 335 form.max_players,
309 pvp, 336 pvp,
337 whitelist_enabled,
310 form.motd, 338 form.motd,
311 form.memory_mb, 339 form.memory_mb,
312 id, 340 id,
@@ -390,8 +418,8 @@ async fn activate_instance(
390 418
391 conn.query_row( 419 conn.query_row(
392 "SELECT id, name, version, server_type, world_source, world_seed, level_type, 420 "SELECT id, name, version, server_type, world_source, world_seed, level_type,
393 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, 421 has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, whitelist_enabled,
394 rcon_password, is_active, created_at, updated_at 422 motd, rcon_password, is_active, created_at, updated_at
395 FROM instances WHERE id = ?1", 423 FROM instances WHERE id = ?1",
396 params![id], 424 params![id],
397 |row| { 425 |row| {
@@ -409,11 +437,12 @@ async fn activate_instance(
409 gamemode: row.get(10)?, 437 gamemode: row.get(10)?,
410 max_players: row.get(11)?, 438 max_players: row.get(11)?,
411 pvp: row.get::<_, i32>(12)? != 0, 439 pvp: row.get::<_, i32>(12)? != 0,
412 motd: row.get(13)?, 440 whitelist_enabled: row.get::<_, i32>(13)? != 0,
413 rcon_password: row.get(14)?, 441 motd: row.get(14)?,
414 is_active: row.get::<_, i32>(15)? != 0, 442 rcon_password: row.get(15)?,
415 created_at: row.get(16)?, 443 is_active: row.get::<_, i32>(16)? != 0,
416 updated_at: row.get(17)?, 444 created_at: row.get(17)?,
445 updated_at: row.get(18)?,
417 }) 446 })
418 }, 447 },
419 ) 448 )
diff --git a/src/routes/mod.rs b/src/routes/mod.rs
index 1cb0e54..a4d200c 100644
--- a/src/routes/mod.rs
+++ b/src/routes/mod.rs
@@ -2,6 +2,7 @@ pub mod accounts;
2pub mod backups; 2pub mod backups;
3pub mod dashboard; 3pub mod dashboard;
4pub mod instances; 4pub mod instances;
5pub mod mods;
5pub mod players; 6pub mod players;
6 7
7use askama::Template; 8use askama::Template;
@@ -35,6 +36,7 @@ pub fn build_router(state: AppState) -> Router {
35 .merge(players::router()) 36 .merge(players::router())
36 .merge(accounts::router()) 37 .merge(accounts::router())
37 .merge(backups::router()) 38 .merge(backups::router())
39 .merge(mods::router())
38 .layer(auth_required); 40 .layer(auth_required);
39 41
40 Router::new() 42 Router::new()
diff --git a/src/routes/mods.rs b/src/routes/mods.rs
new file mode 100644
index 0000000..4716fec
--- /dev/null
+++ b/src/routes/mods.rs
@@ -0,0 +1,422 @@
1use askama::Template;
2use axum::{
3 Router,
4 extract::{Path, State},
5 http::Request,
6 body::Body,
7 response::{Html, Redirect},
8 routing::{get, post},
9};
10use rusqlite::params;
11use std::io::{Cursor, Read};
12
13use crate::auth::CurrentUser;
14use crate::mods;
15use crate::routes::AppState;
16
17#[derive(Template)]
18#[template(path = "mods/list.html")]
19struct ModListTemplate {
20 username: String,
21 is_superuser: bool,
22 password_change_required: bool,
23 instance_name: String,
24 instance_id: i64,
25 mods: Vec<ModRow>,
26 flash: Option<FlashMessage>,
27}
28
29struct ModRow {
30 id: i64,
31 filename: String,
32 display_name: Option<String>,
33 side: String,
34 accepted: bool,
35 rejection_reason: Option<String>,
36 created_at: String,
37}
38
39struct FlashMessage {
40 kind: String,
41 text: String,
42}
43
44pub fn router() -> Router<AppState> {
45 Router::new()
46 .route("/instances/{id}/mods", get(list_mods).post(upload_mod))
47 .route("/instances/{id}/mods/{mod_id}/delete", post(delete_mod))
48 .route("/instances/{id}/mods/rescan", post(rescan_mods))
49}
50
51async fn list_mods(
52 State(state): State<AppState>,
53 Path(instance_id): Path<i64>,
54 CurrentUser(account): CurrentUser,
55) -> Result<Html<String>, axum::response::Redirect> {
56 let conn = state.db.conn();
57 let instance_name: String = conn
58 .query_row(
59 "SELECT name FROM instances WHERE id = ?1",
60 params![instance_id],
61 |row| row.get(0),
62 )
63 .map_err(|_| Redirect::to("/instances"))?;
64
65 let mut stmt = conn
66 .prepare(
67 "SELECT id, filename, display_name, side, accepted, rejection_reason, created_at
68 FROM mods WHERE instance_id = ?1 ORDER BY created_at DESC",
69 )
70 .map_err(|_| Redirect::to("/instances"))?;
71
72 let mods: Vec<ModRow> = stmt
73 .query_map(params![instance_id], |row| {
74 Ok(ModRow {
75 id: row.get(0)?,
76 filename: row.get(1)?,
77 display_name: row.get(2)?,
78 side: row.get(3)?,
79 accepted: row.get::<_, i32>(4)? != 0,
80 rejection_reason: row.get(5)?,
81 created_at: row.get(6)?,
82 })
83 })
84 .map_err(|_| Redirect::to("/instances"))?
85 .filter_map(|r| r.ok())
86 .collect();
87 drop(stmt);
88 drop(conn);
89
90 let template = ModListTemplate {
91 username: account.username.clone(),
92 is_superuser: account.is_superuser,
93 password_change_required: account.password_change_required,
94 instance_name,
95 instance_id,
96 mods,
97 flash: None,
98 };
99
100 match template.render() {
101 Ok(html) => Ok(Html(html)),
102 Err(e) => {
103 tracing::error!("Failed to render mods template: {e}");
104 Err(Redirect::to("/instances"))
105 }
106 }
107}
108
109async fn upload_mod(
110 State(state): State<AppState>,
111 Path(instance_id): Path<i64>,
112 CurrentUser(_user): CurrentUser,
113 req: Request<Body>,
114) -> Result<Redirect, axum::response::Redirect> {
115 tracing::info!("Upload mod request for instance {instance_id}");
116
117 let instance_name = match get_instance_name(&state, instance_id) {
118 Some(n) => n,
119 None => return Err(Redirect::to("/instances")),
120 };
121
122 let mods_dir = state
123 .config
124 .server_directory
125 .join("instances")
126 .join(&instance_name)
127 .join("data")
128 .join("mods");
129 let _ = std::fs::create_dir_all(&mods_dir);
130
131 // Build multer multipart parser with generous size limit (500MB)
132 let (parts, body) = req.into_parts();
133 let content_type = parts
134 .headers
135 .get("content-type")
136 .and_then(|v| v.to_str().ok())
137 .unwrap_or("");
138
139 tracing::info!("Content-Type: {content_type}");
140
141 let boundary = content_type
142 .split(';')
143 .find_map(|part| {
144 let part = part.trim();
145 part.strip_prefix("boundary=").map(|b| b.trim_matches('"').to_string())
146 })
147 .unwrap_or_default();
148
149 if boundary.is_empty() {
150 tracing::error!("No boundary found in Content-Type");
151 return Err(Redirect::to(&format!("/instances/{instance_id}/mods")));
152 }
153
154 let constraints = multer::Constraints::new()
155 .size_limit(multer::SizeLimit::new()
156 .whole_stream(500 * 1024 * 1024)
157 .per_field(500 * 1024 * 1024));
158 let stream = body.into_data_stream();
159 let mut multipart = multer::Multipart::with_constraints(stream, boundary, constraints);
160
161 let mut processed = 0u32;
162 loop {
163 match multipart.next_field().await {
164 Ok(Some(field)) => {
165 let file_name = field
166 .file_name()
167 .map(|s| s.to_string())
168 .unwrap_or_else(|| "mod.jar".to_string());
169 tracing::info!(
170 "Field: {:?}, file: {}, content-type: {:?}",
171 field.name(),
172 file_name,
173 field.content_type().map(|s| s.to_string())
174 );
175
176 // Read field data as bytes
177 let buf = match field.bytes().await {
178 Ok(b) => b.to_vec(),
179 Err(e) => {
180 tracing::error!("Failed to read field bytes: {e}");
181 continue;
182 }
183 };
184
185 if buf.is_empty() {
186 tracing::warn!("Empty field, skipping");
187 continue;
188 }
189 tracing::info!("Read {} bytes from {}", buf.len(), file_name);
190
191 if file_name.ends_with(".zip") || file_name.ends_with(".ZIP") {
192 process_zip_upload(&state, instance_id, &buf, &file_name, &mods_dir);
193 } else if file_name.ends_with(".jar") || file_name.ends_with(".JAR") {
194 process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir);
195 } else {
196 tracing::warn!("Unsupported file type: {file_name}");
197 }
198 processed += 1;
199 }
200 Ok(None) => break,
201 Err(e) => {
202 tracing::error!("Multer error: {e}");
203 break;
204 }
205 }
206 }
207
208 tracing::info!("Upload done, processed {processed} files");
209 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
210}
211
212fn get_instance_name(state: &AppState, id: i64) -> Option<String> {
213 let conn = state.db.conn();
214 let result = conn
215 .query_row(
216 "SELECT name FROM instances WHERE id = ?1",
217 params![id],
218 |row| row.get::<_, String>(0),
219 )
220 .ok();
221 drop(conn);
222 result
223}
224
225fn process_jar_upload(
226 state: &AppState,
227 instance_id: i64,
228 bytes: &[u8],
229 filename: &str,
230 mods_dir: &std::path::Path,
231) {
232 // Strip directory components — use only the basename
233 let safe_name = std::path::Path::new(filename)
234 .file_name()
235 .map(|n| n.to_string_lossy().to_string())
236 .unwrap_or_else(|| filename.to_string());
237
238 tracing::info!("Validating {safe_name}");
239 let result = mods::validate_mod(bytes, &safe_name);
240 tracing::info!(
241 "Result for {safe_name}: accepted={}, side={}",
242 result.accepted,
243 result.side
244 );
245
246 if result.accepted {
247 let dest = mods_dir.join(&safe_name);
248 if std::fs::write(&dest, bytes).is_ok() {
249 tracing::info!("Written to disk: {}", dest.display());
250 } else {
251 tracing::error!("Failed to write {}", dest.display());
252 }
253 }
254
255 let conn = state.db.conn();
256 if let Err(e) = conn.execute(
257 "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason)
258 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
259 params![
260 instance_id,
261 safe_name,
262 result.display_name,
263 result.side.to_string(),
264 result.accepted,
265 result.rejection_reason,
266 ],
267 ) {
268 tracing::error!("DB insert failed: {e}");
269 }
270 drop(conn);
271
272 if result.accepted {
273 let conn = state.db.conn();
274 let _ = conn.execute(
275 "UPDATE instances SET has_modpack = 1 WHERE id = ?1",
276 params![instance_id],
277 );
278 drop(conn);
279 }
280}
281
282fn process_zip_upload(
283 state: &AppState,
284 instance_id: i64,
285 bytes: &[u8],
286 filename: &str,
287 mods_dir: &std::path::Path,
288) {
289 tracing::info!("Extracting ZIP archive {filename}");
290 let cursor = Cursor::new(bytes);
291 let mut archive = match zip::ZipArchive::new(cursor) {
292 Ok(a) => a,
293 Err(e) => {
294 tracing::error!("Failed to read ZIP {filename}: {e}");
295 return;
296 }
297 };
298
299 for i in 0..archive.len() {
300 let mut entry = match archive.by_index(i) {
301 Ok(e) => e,
302 Err(_) => continue,
303 };
304
305 let entry_name = entry.name().to_string();
306 if !entry_name.ends_with(".jar") && !entry_name.ends_with(".JAR") {
307 continue;
308 }
309
310 // Use only the basename (strip directory components)
311 let base_name = std::path::Path::new(&entry_name)
312 .file_name()
313 .map(|n| n.to_string_lossy().to_string())
314 .unwrap_or_else(|| entry_name.clone());
315
316 let mut jar_bytes = Vec::new();
317 if entry.read_to_end(&mut jar_bytes).is_err() {
318 continue;
319 }
320
321 tracing::info!(
322 "Found JAR inside ZIP: {entry_name} => {base_name} ({} bytes)",
323 jar_bytes.len()
324 );
325 process_jar_upload(state, instance_id, &jar_bytes, &base_name, mods_dir);
326 }
327}
328
329async fn rescan_mods(
330 State(state): State<AppState>,
331 Path(instance_id): Path<i64>,
332 CurrentUser(_user): CurrentUser,
333) -> Result<Redirect, axum::response::Redirect> {
334 let instance_name = match get_instance_name(&state, instance_id) {
335 Some(n) => n,
336 None => return Err(Redirect::to("/instances")),
337 };
338
339 let mods_dir = state
340 .config
341 .server_directory
342 .join("instances")
343 .join(&instance_name)
344 .join("data")
345 .join("mods");
346
347 if let Ok(entries) = std::fs::read_dir(&mods_dir) {
348 for entry in entries.flatten() {
349 let path = entry.path();
350 if !path.is_file() {
351 continue;
352 }
353 let filename = path
354 .file_name()
355 .map(|n| n.to_string_lossy().to_string())
356 .unwrap_or_default();
357 if !filename.ends_with(".jar") && !filename.ends_with(".JAR") {
358 continue;
359 }
360 let data = match std::fs::read(&path) {
361 Ok(d) => d,
362 Err(_) => continue,
363 };
364
365 tracing::info!("Rescanning existing mod: {filename}");
366 process_jar_upload(&state, instance_id, &data, &filename, &mods_dir);
367 }
368 }
369
370 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
371}
372
373async fn delete_mod(
374 State(state): State<AppState>,
375 Path((instance_id, mod_id)): Path<(i64, i64)>,
376 CurrentUser(_user): CurrentUser,
377) -> Result<Redirect, axum::response::Redirect> {
378 let conn = state.db.conn();
379 let (filename, instance_name): (String, String) = conn
380 .query_row(
381 "SELECT m.filename, i.name FROM mods m
382 JOIN instances i ON i.id = m.instance_id
383 WHERE m.id = ?1 AND m.instance_id = ?2",
384 params![mod_id, instance_id],
385 |row| Ok((row.get(0)?, row.get(1)?)),
386 )
387 .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
388
389 // Delete file from disk
390 let mod_path = state
391 .config
392 .server_directory
393 .join("instances")
394 .join(&instance_name)
395 .join("data")
396 .join("mods")
397 .join(&filename);
398 let _ = std::fs::remove_file(&mod_path);
399
400 // Remove from DB
401 conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id])
402 .ok();
403
404 // Update has_modpack flag: clear if no mods remain
405 let remaining: i64 = conn
406 .query_row(
407 "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 1",
408 params![instance_id],
409 |row| row.get(0),
410 )
411 .unwrap_or(0);
412 if remaining == 0 {
413 conn.execute(
414 "UPDATE instances SET has_modpack = 0 WHERE id = ?1",
415 params![instance_id],
416 )
417 .ok();
418 }
419 drop(conn);
420
421 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
422}
diff --git a/src/routes/players.rs b/src/routes/players.rs
index 5c90efb..7cfb722 100644
--- a/src/routes/players.rs
+++ b/src/routes/players.rs
@@ -75,14 +75,13 @@ async fn manage_players(
75} 75}
76 76
77fn read_json_list(path: &std::path::Path) -> Vec<String> { 77fn read_json_list(path: &std::path::Path) -> Vec<String> {
78 if let Ok(contents) = std::fs::read_to_string(path) { 78 if let Ok(contents) = std::fs::read_to_string(path)
79 if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) { 79 && let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(&contents) {
80 return entries 80 return entries
81 .iter() 81 .iter()
82 .filter_map(|e| e["name"].as_str().map(String::from)) 82 .filter_map(|e| e["name"].as_str().map(String::from))
83 .collect(); 83 .collect();
84 } 84 }
85 }
86 vec![] 85 vec![]
87} 86}
88 87