diff options
| author | salmonik <salmonik@tuta.io> | 2026-07-29 11:19:12 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-07-29 11:19:12 +0200 |
| commit | 1102f17a93111dca640d790cf9963a9ec9312096 (patch) | |
| tree | 4134d9629fc67bf955a069805b37d5ee6e499c1d | |
| parent | Mod picker: allow selecting multiple .jar files for upload (diff) | |
| download | minecraft-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.rs | 3 | ||||
| -rw-r--r-- | src/routes/mods.rs | 455 | ||||
| -rw-r--r-- | templates/mods/list.html | 69 |
3 files changed, 413 insertions, 114 deletions
| @@ -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 @@ | |||
| 1 | use askama::Template; | 1 | use askama::Template; |
| 2 | use axum::{ | 2 | use 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)] | ||
| 30 | struct ModRow { | 31 | struct 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. | ||
| 48 | fn 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 | |||
| 45 | pub fn router() -> Router<AppState> { | 56 | pub 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 | |||
| 110 | async fn upload_mod( | 128 | async 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. | ||
| 224 | async 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). | ||
| 284 | async 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. | ||
| 345 | async 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. | ||
| 401 | async 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. | ||
| 469 | async 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 | |||
| 219 | fn get_instance_name(state: &AppState, id: i64) -> Option<String> { | 536 | fn 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 | |||
| 389 | async 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> | ||
| 114 | function toggleAll(source) { | ||
| 115 | document.querySelectorAll('input[name="mod_id"]').forEach(cb => cb.checked = source.checked); | ||
| 116 | } | ||
| 117 | |||
| 118 | function 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 %} |
