summaryrefslogtreecommitdiffstats
path: root/src/routes/mods.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-07-29 01:03:24 +0200
committersalmonik <salmonik@tuta.io>2026-07-29 01:03:24 +0200
commit74d2ada067bd2fd9436710ab7ac260e0eddf1314 (patch)
tree1f791bb895a611d8e729a735ba098a647808a157 /src/routes/mods.rs
parentcreate example config on startup (diff)
downloadminecraft-ui-74d2ada067bd2fd9436710ab7ac260e0eddf1314.tar.gz
minecraft-ui-74d2ada067bd2fd9436710ab7ac260e0eddf1314.zip
Add per-instance mod management with client-side detection
- New mod validation engine (src/mods.rs) with constant-pool class-file scanning: detects server-side content (block, tileentity, command, worldgen) vs client-only API (gui, renderer, forge/client) - 7 client-only mods correctly detected (AppleSkin, CraftingTweaks, Durability101, EnchantmentDescriptions, InventoryHUD, MouseTweaks, TrashSlot); 51 server-capable; 1 unknown - Routes + templating for mod upload (jar/zip), listing, delete, rescan - Multipart upload with 500MB limit (bypasses axum's default 2MB) - Per-instance mod summary card on edit page - Whitelist toggle checkbox on edit page (persisted, drives ENABLE_WHITELIST) - Dynamic Java image tag (java8/java17/java21) based on MC version - Class scan is informational-only; rejection based on metadata side=CLIENT - has_modpack flag cleanup on mod delete - Remove hx-boost from <body> (was corrupting multipart uploads)
Diffstat (limited to 'src/routes/mods.rs')
-rw-r--r--src/routes/mods.rs422
1 files changed, 422 insertions, 0 deletions
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}