summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-29 11:19:12 +0200
committersalmonik <salmonik@tuta.io>2026-07-29 11:19:12 +0200
commit1102f17a93111dca640d790cf9963a9ec9312096 (patch)
tree4134d9629fc67bf955a069805b37d5ee6e499c1d
parentMod picker: allow selecting multiple .jar files for upload (diff)
downloadminecraft-ui-1102f17a93111dca640d790cf9963a9ec9312096.tar.gz
minecraft-ui-1102f17a93111dca640d790cf9963a9ec9312096.zip
Enable/disable mods, batch toggle & delete
- Add column to mods table (auto-migrated) - Toggle endpoint: rename .jar ↔ .jar.disabled on disk, flip DB flag - Batch toggle & delete with checkboxes + JS helper (fixes nested-form bug) - Raw Vec<(String,String)> form deserialization fixes 'duplicate field' error - Batch action bars at top and bottom of mod list - Uploads default to enabled=1, rescan skips .jar.disabled files
-rw-r--r--src/db.rs3
-rw-r--r--src/routes/mods.rs455
-rw-r--r--templates/mods/list.html69
3 files changed, 413 insertions, 114 deletions
diff --git a/src/db.rs b/src/db.rs
index 2024032..4d2de88 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -114,6 +114,9 @@ impl Database {
114 let _ = conn.execute_batch( 114 let _ = conn.execute_batch(
115 "ALTER TABLE instances ADD COLUMN whitelist_enabled INTEGER NOT NULL DEFAULT 1", 115 "ALTER TABLE instances ADD COLUMN whitelist_enabled INTEGER NOT NULL DEFAULT 1",
116 ); 116 );
117 // Migration: add enabled column to mods for disable/enable toggling
118 let _ =
119 conn.execute_batch("ALTER TABLE mods ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1");
117 Ok(()) 120 Ok(())
118 } 121 }
119 122
diff --git a/src/routes/mods.rs b/src/routes/mods.rs
index d8f0845..1769aca 100644
--- a/src/routes/mods.rs
+++ b/src/routes/mods.rs
@@ -1,9 +1,9 @@
1use askama::Template; 1use askama::Template;
2use axum::{ 2use axum::{
3 Router, 3 Form, Router,
4 body::Body,
4 extract::{Path, State}, 5 extract::{Path, State},
5 http::Request, 6 http::Request,
6 body::Body,
7 response::{Html, Redirect}, 7 response::{Html, Redirect},
8 routing::{get, post}, 8 routing::{get, post},
9}; 9};
@@ -27,12 +27,14 @@ struct ModListTemplate {
27 flash: Option<FlashMessage>, 27 flash: Option<FlashMessage>,
28} 28}
29 29
30#[derive(Debug)]
30struct ModRow { 31struct ModRow {
31 id: i64, 32 id: i64,
32 filename: String, 33 filename: String,
33 display_name: Option<String>, 34 display_name: Option<String>,
34 side: String, 35 side: String,
35 accepted: bool, 36 accepted: bool,
37 enabled: bool,
36 rejection_reason: Option<String>, 38 rejection_reason: Option<String>,
37 created_at: String, 39 created_at: String,
38} 40}
@@ -42,10 +44,23 @@ struct FlashMessage {
42 text: String, 44 text: String,
43} 45}
44 46
47/// Helper: extract all `mod_id` values from raw form key-value pairs.
48fn parse_mod_ids(pairs: &[(String, String)]) -> Vec<i64> {
49 pairs
50 .iter()
51 .filter(|(k, _)| k == "mod_id")
52 .filter_map(|(_, v)| v.parse().ok())
53 .collect()
54}
55
45pub fn router() -> Router<AppState> { 56pub fn router() -> Router<AppState> {
46 Router::new() 57 Router::new()
47 .route("/instances/{id}/mods", get(list_mods).post(upload_mod)) 58 .route("/instances/{id}/mods", get(list_mods).post(upload_mod))
48 .route("/instances/{id}/mods/{mod_id}/delete", post(delete_mod)) 59 .route("/instances/{id}/mods/{mod_id}/delete", post(delete_mod))
60 .route("/instances/{id}/mods/{mod_id}/toggle", post(toggle_mod))
61 .route("/instances/{id}/mods/batch-disable", post(batch_disable))
62 .route("/instances/{id}/mods/batch-delete", post(batch_delete))
63 .route("/instances/{id}/mods/batch-toggle", post(batch_toggle))
49 .route("/instances/{id}/mods/rescan", post(rescan_mods)) 64 .route("/instances/{id}/mods/rescan", post(rescan_mods))
50} 65}
51 66
@@ -65,7 +80,7 @@ async fn list_mods(
65 80
66 let mut stmt = conn 81 let mut stmt = conn
67 .prepare( 82 .prepare(
68 "SELECT id, filename, display_name, side, accepted, rejection_reason, created_at 83 "SELECT id, filename, display_name, side, accepted, enabled, rejection_reason, created_at
69 FROM mods WHERE instance_id = ?1 ORDER BY created_at DESC", 84 FROM mods WHERE instance_id = ?1 ORDER BY created_at DESC",
70 ) 85 )
71 .map_err(|_| Redirect::to("/instances"))?; 86 .map_err(|_| Redirect::to("/instances"))?;
@@ -78,8 +93,9 @@ async fn list_mods(
78 display_name: row.get(2)?, 93 display_name: row.get(2)?,
79 side: row.get(3)?, 94 side: row.get(3)?,
80 accepted: row.get::<_, i32>(4)? != 0, 95 accepted: row.get::<_, i32>(4)? != 0,
81 rejection_reason: row.get(5)?, 96 enabled: row.get::<_, i32>(5)? != 0,
82 created_at: row.get(6)?, 97 rejection_reason: row.get(6)?,
98 created_at: row.get(7)?,
83 }) 99 })
84 }) 100 })
85 .map_err(|_| Redirect::to("/instances"))? 101 .map_err(|_| Redirect::to("/instances"))?
@@ -107,6 +123,8 @@ async fn list_mods(
107 } 123 }
108} 124}
109 125
126// ── Single-mod actions ───────────────────────────────────────────────
127
110async fn upload_mod( 128async fn upload_mod(
111 State(state): State<AppState>, 129 State(state): State<AppState>,
112 Path(instance_id): Path<i64>, 130 Path(instance_id): Path<i64>,
@@ -120,7 +138,6 @@ async fn upload_mod(
120 None => return Err(Redirect::to("/instances")), 138 None => return Err(Redirect::to("/instances")),
121 }; 139 };
122 140
123 // Sanitize instance name to prevent path traversal
124 let safe_name = match sanitize::sanitize_instance_name(&instance_name) { 141 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
125 Some(n) => n, 142 Some(n) => n,
126 None => return Err(Redirect::to("/instances")), 143 None => return Err(Redirect::to("/instances")),
@@ -135,7 +152,7 @@ async fn upload_mod(
135 .join("mods"); 152 .join("mods");
136 let _ = std::fs::create_dir_all(&mods_dir); 153 let _ = std::fs::create_dir_all(&mods_dir);
137 154
138 // Build multer multipart parser with generous size limit (500MB) 155 // Build multer multipart parser
139 let (parts, body) = req.into_parts(); 156 let (parts, body) = req.into_parts();
140 let content_type = parts 157 let content_type = parts
141 .headers 158 .headers
@@ -143,8 +160,6 @@ async fn upload_mod(
143 .and_then(|v| v.to_str().ok()) 160 .and_then(|v| v.to_str().ok())
144 .unwrap_or(""); 161 .unwrap_or("");
145 162
146 tracing::info!("Content-Type: {content_type}");
147
148 let boundary = content_type 163 let boundary = content_type
149 .split(';') 164 .split(';')
150 .find_map(|part| { 165 .find_map(|part| {
@@ -173,14 +188,6 @@ async fn upload_mod(
173 .file_name() 188 .file_name()
174 .map(|s| s.to_string()) 189 .map(|s| s.to_string())
175 .unwrap_or_else(|| "mod.jar".to_string()); 190 .unwrap_or_else(|| "mod.jar".to_string());
176 tracing::info!(
177 "Field: {:?}, file: {}, content-type: {:?}",
178 field.name(),
179 file_name,
180 field.content_type().map(|s| s.to_string())
181 );
182
183 // Read field data as bytes
184 let buf = match field.bytes().await { 191 let buf = match field.bytes().await {
185 Ok(b) => b.to_vec(), 192 Ok(b) => b.to_vec(),
186 Err(e) => { 193 Err(e) => {
@@ -188,19 +195,15 @@ async fn upload_mod(
188 continue; 195 continue;
189 } 196 }
190 }; 197 };
191
192 if buf.is_empty() { 198 if buf.is_empty() {
193 tracing::warn!("Empty field, skipping");
194 continue; 199 continue;
195 } 200 }
196 tracing::info!("Read {} bytes from {}", buf.len(), file_name);
197 201
198 if file_name.ends_with(".zip") || file_name.ends_with(".ZIP") { 202 let lower = file_name.to_lowercase();
203 if lower.ends_with(".zip") {
199 process_zip_upload(&state, instance_id, &buf, &file_name, &mods_dir); 204 process_zip_upload(&state, instance_id, &buf, &file_name, &mods_dir);
200 } else if file_name.ends_with(".jar") || file_name.ends_with(".JAR") { 205 } else if lower.ends_with(".jar") {
201 process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir); 206 process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir);
202 } else {
203 tracing::warn!("Unsupported file type: {file_name}");
204 } 207 }
205 processed += 1; 208 processed += 1;
206 } 209 }
@@ -216,6 +219,320 @@ async fn upload_mod(
216 Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) 219 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
217} 220}
218 221
222/// Toggle a mod between enabled and disabled.
223/// Disabled = `.jar` renamed to `.jar.disabled` on disk so the server skips it.
224async fn toggle_mod(
225 State(state): State<AppState>,
226 Path((instance_id, mod_id)): Path<(i64, i64)>,
227 CurrentUser(_user): CurrentUser,
228) -> Result<Redirect, axum::response::Redirect> {
229 let conn = state.db.conn();
230 let (filename, instance_name, currently_enabled): (String, String, bool) = conn
231 .query_row(
232 "SELECT m.filename, i.name, m.enabled FROM mods m
233 JOIN instances i ON i.id = m.instance_id
234 WHERE m.id = ?1 AND m.instance_id = ?2",
235 params![mod_id, instance_id],
236 |row| Ok((row.get(0)?, row.get(1)?, row.get::<_, i32>(2)? != 0)),
237 )
238 .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
239 drop(conn);
240
241 let safe_name = sanitize::sanitize_instance_name(&instance_name)
242 .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
243 let safe_filename = sanitize::sanitize_backup_filename(&filename)
244 .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
245
246 let mods_dir = state
247 .config
248 .server_directory
249 .join("instances")
250 .join(&safe_name)
251 .join("data")
252 .join("mods");
253
254 let base_path = mods_dir.join(&safe_filename);
255
256 if currently_enabled {
257 // Disable: rename .jar → .jar.disabled
258 let disabled_path = mods_dir.join(format!("{safe_filename}.disabled"));
259 if base_path.exists() {
260 let _ = std::fs::rename(&base_path, &disabled_path);
261 }
262 } else {
263 // Enable: rename .jar.disabled → .jar
264 let disabled_path = mods_dir.join(format!("{safe_filename}.disabled"));
265 if disabled_path.exists() {
266 let _ = std::fs::rename(&disabled_path, &base_path);
267 } else if !base_path.exists() {
268 // Neither exists — file may have been deleted out of band; just toggle the DB
269 tracing::warn!("Mod file not found on disk: {safe_filename}");
270 }
271 }
272
273 let conn = state.db.conn();
274 let _ = conn.execute(
275 "UPDATE mods SET enabled = NOT enabled WHERE id = ?1",
276 params![mod_id],
277 );
278 drop(conn);
279
280 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
281}
282
283/// Permanently delete a single mod (file + DB record).
284async fn delete_mod(
285 State(state): State<AppState>,
286 Path((instance_id, mod_id)): Path<(i64, i64)>,
287 CurrentUser(_user): CurrentUser,
288) -> Result<Redirect, axum::response::Redirect> {
289 let conn = state.db.conn();
290 let (filename, instance_name): (String, String) = conn
291 .query_row(
292 "SELECT m.filename, i.name FROM mods m
293 JOIN instances i ON i.id = m.instance_id
294 WHERE m.id = ?1 AND m.instance_id = ?2",
295 params![mod_id, instance_id],
296 |row| Ok((row.get(0)?, row.get(1)?)),
297 )
298 .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
299
300 let safe_name = sanitize::sanitize_instance_name(&instance_name)
301 .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
302 let safe_filename = sanitize::sanitize_backup_filename(&filename)
303 .ok_or_else(|| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
304
305 let mods_dir = state
306 .config
307 .server_directory
308 .join("instances")
309 .join(&safe_name)
310 .join("data")
311 .join("mods");
312
313 // Try deleting both the .jar and the .jar.disabled variant
314 let jar_path = mods_dir.join(&safe_filename);
315 let disabled_path = mods_dir.join(format!("{safe_filename}.disabled"));
316 let _ = std::fs::remove_file(&jar_path);
317 let _ = std::fs::remove_file(&disabled_path);
318
319 // Remove from DB
320 conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id])
321 .ok();
322
323 let remaining: i64 = conn
324 .query_row(
325 "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 1",
326 params![instance_id],
327 |row| row.get(0),
328 )
329 .unwrap_or(0);
330 if remaining == 0 {
331 conn.execute(
332 "UPDATE instances SET has_modpack = 0 WHERE id = ?1",
333 params![instance_id],
334 )
335 .ok();
336 }
337 drop(conn);
338
339 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
340}
341
342// ── Batch actions ─────────────────────────────────────────────────────
343
344/// Disable all checked mods at once.
345async fn batch_disable(
346 State(state): State<AppState>,
347 Path(instance_id): Path<i64>,
348 CurrentUser(_user): CurrentUser,
349 Form(form): Form<Vec<(String, String)>>,
350) -> Result<Redirect, axum::response::Redirect> {
351 let mod_ids = parse_mod_ids(&form);
352 for mod_id in &mod_ids {
353 let conn = state.db.conn();
354 let (filename, instance_name): (String, String) = match conn.query_row(
355 "SELECT m.filename, i.name FROM mods m
356 JOIN instances i ON i.id = m.instance_id
357 WHERE m.id = ?1 AND m.instance_id = ?2",
358 params![mod_id, instance_id],
359 |row| Ok((row.get(0)?, row.get(1)?)),
360 ) {
361 Ok(v) => v,
362 Err(_) => continue,
363 };
364 drop(conn);
365
366 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
367 Some(n) => n,
368 None => continue,
369 };
370 let safe_filename = match sanitize::sanitize_backup_filename(&filename) {
371 Some(f) => f,
372 None => continue,
373 };
374
375 let mods_dir = state
376 .config
377 .server_directory
378 .join("instances")
379 .join(&safe_name)
380 .join("data")
381 .join("mods");
382
383 let jar = mods_dir.join(&safe_filename);
384 let disabled = mods_dir.join(format!("{safe_filename}.disabled"));
385 if jar.exists() {
386 let _ = std::fs::rename(&jar, &disabled);
387 }
388
389 let conn = state.db.conn();
390 let _ = conn.execute(
391 "UPDATE mods SET enabled = 0 WHERE id = ?1",
392 params![mod_id],
393 );
394 drop(conn);
395 }
396
397 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
398}
399
400/// Permanently delete all checked mods at once.
401async fn batch_delete(
402 State(state): State<AppState>,
403 Path(instance_id): Path<i64>,
404 CurrentUser(_user): CurrentUser,
405 Form(form): Form<Vec<(String, String)>>,
406) -> Result<Redirect, axum::response::Redirect> {
407 let mod_ids = parse_mod_ids(&form);
408 for mod_id in &mod_ids {
409 let conn = state.db.conn();
410 let (filename, instance_name): (String, String) = match conn.query_row(
411 "SELECT m.filename, i.name FROM mods m
412 JOIN instances i ON i.id = m.instance_id
413 WHERE m.id = ?1 AND m.instance_id = ?2",
414 params![mod_id, instance_id],
415 |row| Ok((row.get(0)?, row.get(1)?)),
416 ) {
417 Ok(v) => v,
418 Err(_) => continue,
419 };
420 drop(conn);
421
422 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
423 Some(n) => n,
424 None => continue,
425 };
426 let safe_filename = match sanitize::sanitize_backup_filename(&filename) {
427 Some(f) => f,
428 None => continue,
429 };
430
431 let mods_dir = state
432 .config
433 .server_directory
434 .join("instances")
435 .join(&safe_name)
436 .join("data")
437 .join("mods");
438
439 let _ = std::fs::remove_file(mods_dir.join(&safe_filename));
440 let _ = std::fs::remove_file(mods_dir.join(format!("{safe_filename}.disabled")));
441
442 let conn = state.db.conn();
443 conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id])
444 .ok();
445 drop(conn);
446 }
447
448 // Update has_modpack flag
449 let conn = state.db.conn();
450 let remaining: i64 = conn
451 .query_row(
452 "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 1",
453 params![instance_id],
454 |row| row.get(0),
455 )
456 .unwrap_or(0);
457 if remaining == 0 {
458 let _ = conn.execute(
459 "UPDATE instances SET has_modpack = 0 WHERE id = ?1",
460 params![instance_id],
461 );
462 }
463 drop(conn);
464
465 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
466}
467
468/// Toggle (enable/disable) all checked mods at once — each flips its state.
469async fn batch_toggle(
470 State(state): State<AppState>,
471 Path(instance_id): Path<i64>,
472 CurrentUser(_user): CurrentUser,
473 Form(form): Form<Vec<(String, String)>>,
474) -> Result<Redirect, axum::response::Redirect> {
475 let mod_ids = parse_mod_ids(&form);
476 for mod_id in &mod_ids {
477 let conn = state.db.conn();
478 let (filename, instance_name, currently_enabled): (String, String, bool) =
479 match conn.query_row(
480 "SELECT m.filename, i.name, m.enabled FROM mods m
481 JOIN instances i ON i.id = m.instance_id
482 WHERE m.id = ?1 AND m.instance_id = ?2",
483 params![mod_id, instance_id],
484 |row| Ok((row.get(0)?, row.get(1)?, row.get::<_, i32>(2)? != 0)),
485 ) {
486 Ok(v) => v,
487 Err(_) => continue,
488 };
489 drop(conn);
490
491 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
492 Some(n) => n,
493 None => continue,
494 };
495 let safe_filename = match sanitize::sanitize_backup_filename(&filename) {
496 Some(f) => f,
497 None => continue,
498 };
499
500 let mods_dir = state
501 .config
502 .server_directory
503 .join("instances")
504 .join(&safe_name)
505 .join("data")
506 .join("mods");
507
508 let jar = mods_dir.join(&safe_filename);
509 let disabled = mods_dir.join(format!("{safe_filename}.disabled"));
510
511 if currently_enabled {
512 // Was enabled → now disable
513 if jar.exists() {
514 let _ = std::fs::rename(&jar, &disabled);
515 }
516 } else {
517 // Was disabled → now enable
518 if disabled.exists() {
519 let _ = std::fs::rename(&disabled, &jar);
520 }
521 }
522
523 let conn = state.db.conn();
524 let _ = conn.execute(
525 "UPDATE mods SET enabled = NOT enabled WHERE id = ?1",
526 params![mod_id],
527 );
528 drop(conn);
529 }
530
531 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
532}
533
534// ── Helpers ───────────────────────────────────────────────────────────
535
219fn get_instance_name(state: &AppState, id: i64) -> Option<String> { 536fn get_instance_name(state: &AppState, id: i64) -> Option<String> {
220 let conn = state.db.conn(); 537 let conn = state.db.conn();
221 let result = conn 538 let result = conn
@@ -236,7 +553,6 @@ fn process_jar_upload(
236 filename: &str, 553 filename: &str,
237 mods_dir: &std::path::Path, 554 mods_dir: &std::path::Path,
238) { 555) {
239 // Strip directory components — use only the basename
240 let safe_name = std::path::Path::new(filename) 556 let safe_name = std::path::Path::new(filename)
241 .file_name() 557 .file_name()
242 .map(|n| n.to_string_lossy().to_string()) 558 .map(|n| n.to_string_lossy().to_string())
@@ -244,18 +560,11 @@ fn process_jar_upload(
244 560
245 tracing::info!("Validating {safe_name}"); 561 tracing::info!("Validating {safe_name}");
246 let result = mods::validate_mod(bytes, &safe_name); 562 let result = mods::validate_mod(bytes, &safe_name);
247 tracing::info!(
248 "Result for {safe_name}: accepted={}, side={}",
249 result.accepted,
250 result.side
251 );
252 563
253 // FIX: Insert DB record BEFORE writing file to disk.
254 // This prevents orphaned mod files if the process crashes between write and DB insert.
255 let conn = state.db.conn(); 564 let conn = state.db.conn();
256 if let Err(e) = conn.execute( 565 if let Err(e) = conn.execute(
257 "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) 566 "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason, enabled)
258 VALUES (?1, ?2, ?3, ?4, ?5, ?6)", 567 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
259 params![ 568 params![
260 instance_id, 569 instance_id,
261 safe_name, 570 safe_name,
@@ -271,7 +580,6 @@ fn process_jar_upload(
271 } 580 }
272 drop(conn); 581 drop(conn);
273 582
274 // Now write to disk (DB insert succeeded, so on restart we can detect incomplete writes)
275 if result.accepted { 583 if result.accepted {
276 let dest = mods_dir.join(&safe_name); 584 let dest = mods_dir.join(&safe_name);
277 if std::fs::write(&dest, bytes).is_ok() { 585 if std::fs::write(&dest, bytes).is_ok() {
@@ -306,18 +614,18 @@ fn process_zip_upload(
306 } 614 }
307 }; 615 };
308 616
309 for i in 0..archive.len() { 617 for i in 0..archive.len().min(500) {
310 let mut entry = match archive.by_index(i) { 618 let mut entry = match archive.by_index(i) {
311 Ok(e) => e, 619 Ok(e) => e,
312 Err(_) => continue, 620 Err(_) => continue,
313 }; 621 };
314 622
315 let entry_name = entry.name().to_string(); 623 let entry_name = entry.name().to_string();
316 if !entry_name.ends_with(".jar") && !entry_name.ends_with(".JAR") { 624 let lower = entry_name.to_lowercase();
625 if !lower.ends_with(".jar") {
317 continue; 626 continue;
318 } 627 }
319 628
320 // Use only the basename (strip directory components)
321 let base_name = std::path::Path::new(&entry_name) 629 let base_name = std::path::Path::new(&entry_name)
322 .file_name() 630 .file_name()
323 .map(|n| n.to_string_lossy().to_string()) 631 .map(|n| n.to_string_lossy().to_string())
@@ -328,10 +636,6 @@ fn process_zip_upload(
328 continue; 636 continue;
329 } 637 }
330 638
331 tracing::info!(
332 "Found JAR inside ZIP: {entry_name} => {base_name} ({} bytes)",
333 jar_bytes.len()
334 );
335 process_jar_upload(state, instance_id, &jar_bytes, &base_name, mods_dir); 639 process_jar_upload(state, instance_id, &jar_bytes, &base_name, mods_dir);
336 } 640 }
337} 641}
@@ -346,7 +650,6 @@ async fn rescan_mods(
346 None => return Err(Redirect::to("/instances")), 650 None => return Err(Redirect::to("/instances")),
347 }; 651 };
348 652
349 // Sanitize instance name to prevent path traversal
350 let safe_name = match sanitize::sanitize_instance_name(&instance_name) { 653 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
351 Some(n) => n, 654 Some(n) => n,
352 None => return Err(Redirect::to("/instances")), 655 None => return Err(Redirect::to("/instances")),
@@ -370,7 +673,8 @@ async fn rescan_mods(
370 .file_name() 673 .file_name()
371 .map(|n| n.to_string_lossy().to_string()) 674 .map(|n| n.to_string_lossy().to_string())
372 .unwrap_or_default(); 675 .unwrap_or_default();
373 if !filename.ends_with(".jar") && !filename.ends_with(".JAR") { 676 // Skip disabled mods (intentionally deactivated) and non-jar files
677 if !filename.ends_with(".jar") || filename.ends_with(".jar.disabled") {
374 continue; 678 continue;
375 } 679 }
376 let data = match std::fs::read(&path) { 680 let data = match std::fs::read(&path) {
@@ -385,66 +689,3 @@ async fn rescan_mods(
385 689
386 Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) 690 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
387} 691}
388
389async fn delete_mod(
390 State(state): State<AppState>,
391 Path((instance_id, mod_id)): Path<(i64, i64)>,
392 CurrentUser(_user): CurrentUser,
393) -> Result<Redirect, axum::response::Redirect> {
394 let conn = state.db.conn();
395 let (filename, instance_name): (String, String) = conn
396 .query_row(
397 "SELECT m.filename, i.name FROM mods m
398 JOIN instances i ON i.id = m.instance_id
399 WHERE m.id = ?1 AND m.instance_id = ?2",
400 params![mod_id, instance_id],
401 |row| Ok((row.get(0)?, row.get(1)?)),
402 )
403 .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?;
404
405 // Sanitize instance name to prevent path traversal
406 let safe_name = match sanitize::sanitize_instance_name(&instance_name) {
407 Some(n) => n,
408 None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))),
409 };
410
411 // Sanitize filename to prevent path traversal
412 let safe_filename = match sanitize::sanitize_backup_filename(&filename) {
413 Some(f) => f,
414 None => return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))),
415 };
416
417 // Delete file from disk
418 let mod_path = state
419 .config
420 .server_directory
421 .join("instances")
422 .join(&safe_name)
423 .join("data")
424 .join("mods")
425 .join(&safe_filename);
426 let _ = std::fs::remove_file(&mod_path);
427
428 // Remove from DB
429 conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id])
430 .ok();
431
432 // Update has_modpack flag: clear if no mods remain
433 let remaining: i64 = conn
434 .query_row(
435 "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 1",
436 params![instance_id],
437 |row| row.get(0),
438 )
439 .unwrap_or(0);
440 if remaining == 0 {
441 conn.execute(
442 "UPDATE instances SET has_modpack = 0 WHERE id = ?1",
443 params![instance_id],
444 )
445 .ok();
446 }
447 drop(conn);
448
449 Ok(Redirect::to(&format!("/instances/{instance_id}/mods")))
450}
diff --git a/templates/mods/list.html b/templates/mods/list.html
index 4e75039..b5420b8 100644
--- a/templates/mods/list.html
+++ b/templates/mods/list.html
@@ -16,7 +16,7 @@
16 </form> 16 </form>
17 <p style="margin-top:0.5rem;font-size:0.8rem;color:var(--fg-muted)"> 17 <p style="margin-top:0.5rem;font-size:0.8rem;color:var(--fg-muted)">
18 Select one or more <code>.jar</code> files, or a <code>.zip</code> containing multiple <code>.jar</code>s. 18 Select one or more <code>.jar</code> files, or a <code>.zip</code> containing multiple <code>.jar</code>s.
19 Mods containing <strong>only</strong> client-side classes will be rejected. 19 Disabled mods are renamed to <code>.jar.disabled</code> so the server skips them.
20 </p> 20 </p>
21</div> 21</div>
22 22
@@ -25,9 +25,16 @@
25 <p>No mods installed. Upload a mod to get started.</p> 25 <p>No mods installed. Upload a mod to get started.</p>
26</div> 26</div>
27{% else %} 27{% else %}
28<table class="table"> 28<div class="batch-bar" style="margin-bottom:0.75rem;display:flex;gap:0.5rem;align-items:center">
29 <span style="font-size:0.85rem;color:var(--fg-muted)">With selected:</span>
30 <button type="button" class="btn" id="batch-toggle-btn" onclick="batchSubmit('toggle')">Toggle</button>
31 <button type="button" class="btn-danger"
32 onclick="if(confirm('Permanently delete selected mods?')) batchSubmit('delete')">Delete</button>
33</div>
34<table class="table" id="mods-table">
29 <thead> 35 <thead>
30 <tr> 36 <tr>
37 <th><input type="checkbox" id="select-all" onclick="toggleAll(this)"></th>
31 <th>Name</th> 38 <th>Name</th>
32 <th>Filename</th> 39 <th>Filename</th>
33 <th>Side</th> 40 <th>Side</th>
@@ -38,7 +45,8 @@
38 </thead> 45 </thead>
39 <tbody> 46 <tbody>
40 {% for m in mods %} 47 {% for m in mods %}
41 <tr> 48 <tr class="{% if !m.enabled %}row-dim{% endif %}">
49 <td><input type="checkbox" name="mod_id" value="{{ m.id }}" data-enabled="{{ m.enabled }}"></td>
42 <td> 50 <td>
43 {% if let Some(name) = m.display_name %} 51 {% if let Some(name) = m.display_name %}
44 {{ name }} 52 {{ name }}
@@ -58,26 +66,42 @@
58 <td> 66 <td>
59 {% if !m.accepted %} 67 {% if !m.accepted %}
60 <span class="status-badge status-error">Rejected</span> 68 <span class="status-badge status-error">Rejected</span>
69 {% else if !m.enabled %}
70 <span class="status-badge status-warning">Disabled</span>
61 {% else if m.side == "CLIENT" %} 71 {% else if m.side == "CLIENT" %}
62 <span class="status-badge status-warning">Warning</span> 72 <span class="status-badge status-warning">Warning</span>
63 {% else %} 73 {% else %}
64 <span class="status-badge status-running">Accepted</span> 74 <span class="status-badge status-running">Active</span>
65 {% endif %} 75 {% endif %}
66 {% if let Some(reason) = m.rejection_reason %} 76 {% if let Some(reason) = m.rejection_reason %}
67 <div style="font-size:0.8rem;color:var(--err);margin-top:0.25rem">{{ reason }}</div> 77 <div style="font-size:0.8rem;color:var(--err);margin-top:0.25rem">{{ reason }}</div>
68 {% endif %} 78 {% endif %}
69 </td> 79 </td>
70 <td>{{ m.created_at }}</td> 80 <td>{{ m.created_at }}</td>
71 <td> 81 <td style="white-space:nowrap">
82 <form action="/instances/{{ instance_id }}/mods/{{ m.id }}/toggle" method="post" style="display:inline">
83 <button type="submit" class="btn-sm {% if m.enabled %}btn{% else %}btn-ok{% endif %}">
84 {% if m.enabled %}Disable{% else %}Enable{% endif %}
85 </button>
86 </form>
72 <form action="/instances/{{ instance_id }}/mods/{{ m.id }}/delete" method="post" style="display:inline" 87 <form action="/instances/{{ instance_id }}/mods/{{ m.id }}/delete" method="post" style="display:inline"
73 onsubmit="return confirm('Remove this mod?')"> 88 onsubmit="return confirm('Permanently remove this mod?')">
74 <button type="submit" class="btn-danger btn-sm">Remove</button> 89 <button type="submit" class="btn-danger btn-sm">Delete</button>
75 </form> 90 </form>
76 </td> 91 </td>
77 </tr> 92 </tr>
78 {% endfor %} 93 {% endfor %}
79 </tbody> 94 </tbody>
80</table> 95</table>
96
97<form id="batch-form" method="post" style="display:none"></form>
98
99<div class="batch-bar" style="margin-top:0.75rem;display:flex;gap:0.5rem;align-items:center">
100 <span style="font-size:0.85rem;color:var(--fg-muted)">With selected:</span>
101 <button type="button" class="btn" id="batch-toggle-btn2" onclick="batchSubmit('toggle')">Toggle</button>
102 <button type="button" class="btn-danger"
103 onclick="if(confirm('Permanently delete selected mods?')) batchSubmit('delete')">Delete</button>
104</div>
81{% endif %} 105{% endif %}
82 106
83<div style="margin-top:1rem"> 107<div style="margin-top:1rem">
@@ -85,4 +109,35 @@
85 <button type="submit" class="btn">Rescan All Mods</button> 109 <button type="submit" class="btn">Rescan All Mods</button>
86 </form> 110 </form>
87</div> 111</div>
112
113<script>
114function toggleAll(source) {
115 document.querySelectorAll('input[name="mod_id"]').forEach(cb => cb.checked = source.checked);
116}
117
118function batchSubmit(action) {
119 const checked = document.querySelectorAll('input[name="mod_id"]:checked');
120 if (checked.length === 0) return;
121
122 const form = document.getElementById('batch-form');
123 // Clear any previous hidden inputs
124 form.textContent = '';
125 form.action = '/instances/{{ instance_id }}/mods/batch-' + action;
126
127 for (const cb of checked) {
128 const input = document.createElement('input');
129 input.type = 'hidden';
130 input.name = 'mod_id';
131 input.value = cb.value;
132 form.appendChild(input);
133 }
134
135 form.submit();
136}
137</script>
138
139<style>
140.row-dim { opacity: 0.55; }
141.row-dim:hover { opacity: 0.85; }
142</style>
88{% endblock %} 143{% endblock %}