summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/auth.rs27
-rw-r--r--src/db.rs16
-rw-r--r--src/docker.rs61
-rw-r--r--src/main.rs1
-rw-r--r--src/models/mod.rs14
-rw-r--r--src/mods.rs480
-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
11 files changed, 1047 insertions, 50 deletions
diff --git a/src/auth.rs b/src/auth.rs
index 8fa62ec..3c89e37 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -97,15 +97,17 @@ pub async fn require_auth(
97 mut request: Request, 97 mut request: Request,
98 next: Next, 98 next: Next,
99) -> Result<Response, Response> { 99) -> Result<Response, Response> {
100 let cookies = request 100 let raw_cookies = request
101 .headers() 101 .headers()
102 .get(axum::http::header::COOKIE) 102 .get(axum::http::header::COOKIE)
103 .and_then(|v| v.to_str().ok()) 103 .and_then(|v| v.to_str().ok())
104 .unwrap_or(""); 104 .unwrap_or("[no cookie header]")
105 .to_string();
105 106
106 let session_id = cookies.split(';').map(|s| s.trim()).find_map(|cookie| { 107 let session_id = raw_cookies.split(';').map(|s| s.trim()).find_map(|cookie| {
107 if let Some((name, value)) = cookie.split_once('=') { 108 if let Some((name, value)) = cookie.split_once('=') {
108 if name.trim() == SESSION_COOKIE { 109 let n = name.trim();
110 if n == SESSION_COOKIE {
109 Some(value.trim().to_string()) 111 Some(value.trim().to_string())
110 } else { 112 } else {
111 None 113 None
@@ -137,13 +139,18 @@ pub async fn require_auth(
137 .ok(); 139 .ok();
138 140
139 if let Some(account) = account { 141 if let Some(account) = account {
142 tracing::debug!("Auth OK for {}", account.username);
140 request.extensions_mut().insert(account); 143 request.extensions_mut().insert(account);
141 Ok(next.run(request).await) 144 Ok(next.run(request).await)
142 } else { 145 } else {
146 tracing::warn!("Session {id} not found in DB (raw cookies: {raw_cookies})");
143 Err(Redirect::to("/login").into_response()) 147 Err(Redirect::to("/login").into_response())
144 } 148 }
145 } 149 }
146 None => Err(Redirect::to("/login").into_response()), 150 None => {
151 tracing::warn!("No session cookie found (raw cookies: {raw_cookies})");
152 Err(Redirect::to("/login").into_response())
153 }
147 } 154 }
148} 155}
149 156
@@ -170,21 +177,19 @@ pub async fn logout(
170 State(state): State<AppState>, 177 State(state): State<AppState>,
171 headers: axum::http::HeaderMap, 178 headers: axum::http::HeaderMap,
172) -> impl IntoResponse { 179) -> impl IntoResponse {
173 if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) { 180 if let Some(cookie_header) = headers.get(axum::http::header::COOKIE)
174 if let Ok(cookie_str) = cookie_header.to_str() { 181 && let Ok(cookie_str) = cookie_header.to_str() {
175 for part in cookie_str.split(';') { 182 for part in cookie_str.split(';') {
176 let part = part.trim(); 183 let part = part.trim();
177 if let Some((name, value)) = part.split_once('=') { 184 if let Some((name, value)) = part.split_once('=')
178 if name.trim() == SESSION_COOKIE { 185 && name.trim() == SESSION_COOKIE {
179 let _ = state 186 let _ = state
180 .db 187 .db
181 .conn() 188 .conn()
182 .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]); 189 .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]);
183 } 190 }
184 }
185 } 191 }
186 } 192 }
187 }
188 193
189 let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0"); 194 let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0");
190 ( 195 (
diff --git a/src/db.rs b/src/db.rs
index 7868e3e..2024032 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -62,6 +62,18 @@ impl Database {
62 updated_at TEXT NOT NULL DEFAULT (datetime('now')) 62 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
63 ); 63 );
64 64
65 CREATE TABLE IF NOT EXISTS mods (
66 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 instance_id INTEGER NOT NULL,
68 filename TEXT NOT NULL,
69 display_name TEXT,
70 side TEXT NOT NULL DEFAULT 'UNKNOWN',
71 accepted INTEGER NOT NULL DEFAULT 0,
72 rejection_reason TEXT,
73 created_at TEXT NOT NULL DEFAULT (datetime('now')),
74 FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
75 );
76
65 CREATE TABLE IF NOT EXISTS backups ( 77 CREATE TABLE IF NOT EXISTS backups (
66 id INTEGER PRIMARY KEY AUTOINCREMENT, 78 id INTEGER PRIMARY KEY AUTOINCREMENT,
67 instance_id INTEGER NOT NULL, 79 instance_id INTEGER NOT NULL,
@@ -98,6 +110,10 @@ impl Database {
98 let _ = conn.execute_batch( 110 let _ = conn.execute_batch(
99 "ALTER TABLE instances ADD COLUMN container_state TEXT NOT NULL DEFAULT 'stopped'", 111 "ALTER TABLE instances ADD COLUMN container_state TEXT NOT NULL DEFAULT 'stopped'",
100 ); 112 );
113 // Migration: add whitelist_enabled column if missing
114 let _ = conn.execute_batch(
115 "ALTER TABLE instances ADD COLUMN whitelist_enabled INTEGER NOT NULL DEFAULT 1",
116 );
101 Ok(()) 117 Ok(())
102 } 118 }
103 119
diff --git a/src/docker.rs b/src/docker.rs
index 6b8640a..d66767e 100644
--- a/src/docker.rs
+++ b/src/docker.rs
@@ -23,19 +23,51 @@ impl DockerManager {
23 Ok(Self { client, config }) 23 Ok(Self { client, config })
24 } 24 }
25 25
26 /// Map a Minecraft version string (e.g. "1.12.2", "1.20.1") to the
27 /// appropriate itzg/minecraft-server Java image tag.
28 ///
29 /// - < 1.17 → java8 (Minecraft 1.12.2 and earlier)
30 /// - 1.17 .. 1.20.4 → java17
31 /// - >= 1.20.5 → java21
32 fn java_tag(version: &str) -> &'static str {
33 // Parse major.minor from version strings like "1.12.2", "1.20.1", "1.21"
34 let parts: Vec<&str> = version.split('.').collect();
35 if parts.len() < 2 {
36 return "java17"; // sensible default
37 }
38 let major: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(1);
39 let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(12);
40
41 if major <= 1 && minor < 17 {
42 "java8"
43 } else if major <= 1 && minor < 21 {
44 // 1.17.x through 1.20.4 use Java 17
45 "java17"
46 } else {
47 // 1.20.5+ and 1.21+ use Java 21
48 "java21"
49 }
50 }
51
52 /// Build the full image reference, e.g. "itzg/minecraft-server:java8"
53 fn image_ref(version: &str) -> String {
54 format!("itzg/minecraft-server:{}", Self::java_tag(version))
55 }
56
26 pub async fn start_container(&self, instance: &Instance) -> Result<(), String> { 57 pub async fn start_container(&self, instance: &Instance) -> Result<(), String> {
27 tracing::info!("Pulling itzg/minecraft-server:latest image..."); 58 let tag = Self::java_tag(&instance.version);
28 self.pull_image().await?; 59 tracing::info!("Pulling itzg/minecraft-server:{tag} image...");
60 self.pull_image(tag).await?;
29 tracing::info!("Creating container for instance '{}'", instance.name); 61 tracing::info!("Creating container for instance '{}'", instance.name);
30 self.recreate_container(instance).await?; 62 self.recreate_container(instance).await?;
31 tracing::info!("Starting container..."); 63 tracing::info!("Starting container...");
32 self.start_existing().await 64 self.start_existing().await
33 } 65 }
34 66
35 async fn pull_image(&self) -> Result<(), String> { 67 async fn pull_image(&self, tag: &str) -> Result<(), String> {
36 let options = CreateImageOptions { 68 let options = CreateImageOptions {
37 from_image: "itzg/minecraft-server", 69 from_image: "itzg/minecraft-server",
38 tag: "latest", 70 tag,
39 ..Default::default() 71 ..Default::default()
40 }; 72 };
41 73
@@ -74,6 +106,8 @@ impl DockerManager {
74 .to_string(); 106 .to_string();
75 let instance_data = format!("{server_dir}/instances/{}/data", instance.name); 107 let instance_data = format!("{server_dir}/instances/{}/data", instance.name);
76 108
109 let image_ref = Self::image_ref(&instance.version);
110
77 let mut env_strings: Vec<String> = vec![ 111 let mut env_strings: Vec<String> = vec![
78 "EULA=TRUE".into(), 112 "EULA=TRUE".into(),
79 format!("VERSION={}", instance.version), 113 format!("VERSION={}", instance.version),
@@ -83,7 +117,7 @@ impl DockerManager {
83 format!("DIFFICULTY={}", instance.difficulty), 117 format!("DIFFICULTY={}", instance.difficulty),
84 "FORCE_GAMEMODE=true".into(), 118 "FORCE_GAMEMODE=true".into(),
85 "SPAWN_PROTECTION=0".into(), 119 "SPAWN_PROTECTION=0".into(),
86 "ENABLE_WHITELIST=true".into(), 120 format!("ENABLE_WHITELIST={}", instance.whitelist_enabled),
87 "PAUSE_WHEN_EMPTY_SECONDS=30".into(), 121 "PAUSE_WHEN_EMPTY_SECONDS=30".into(),
88 "ONLINE_MODE=false".into(), 122 "ONLINE_MODE=false".into(),
89 "TZ=Europe/Warsaw".into(), 123 "TZ=Europe/Warsaw".into(),
@@ -102,17 +136,14 @@ impl DockerManager {
102 env_strings.push("PVP=false".into()); 136 env_strings.push("PVP=false".into());
103 } 137 }
104 138
105 if let Some(ref seed) = instance.world_seed { 139 if let Some(ref seed) = instance.world_seed
106 if !seed.is_empty() { 140 && !seed.is_empty() {
107 env_strings.push(format!("LEVEL_SEED={seed}")); 141 env_strings.push(format!("LEVEL_SEED={seed}"));
108 } 142 }
109 }
110
111 let env_refs: Vec<&str> = env_strings.iter().map(|s| s.as_str()).collect();
112 143
113 let mut exposed_ports: HashMap<&str, HashMap<(), ()>> = HashMap::new(); 144 let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
114 exposed_ports.insert("25565/tcp", HashMap::new()); 145 exposed_ports.insert("25565/tcp".into(), HashMap::new());
115 exposed_ports.insert("25575/tcp", HashMap::new()); 146 exposed_ports.insert("25575/tcp".into(), HashMap::new());
116 147
117 let mut port_bindings = HashMap::new(); 148 let mut port_bindings = HashMap::new();
118 port_bindings.insert( 149 port_bindings.insert(
@@ -137,9 +168,9 @@ impl DockerManager {
137 }; 168 };
138 169
139 let container_config = ContainerConfig { 170 let container_config = ContainerConfig {
140 image: Some("itzg/minecraft-server:latest"), 171 image: Some(image_ref.clone()),
141 tty: Some(true), 172 tty: Some(true),
142 env: Some(env_refs), 173 env: Some(env_strings.clone()),
143 exposed_ports: Some(exposed_ports), 174 exposed_ports: Some(exposed_ports),
144 host_config: Some(host_config), 175 host_config: Some(host_config),
145 ..Default::default() 176 ..Default::default()
diff --git a/src/main.rs b/src/main.rs
index 695774a..a3cc430 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@ mod config;
3mod db; 3mod db;
4mod docker; 4mod docker;
5mod models; 5mod models;
6mod mods;
6mod properties; 7mod properties;
7mod rcon; 8mod rcon;
8mod routes; 9mod routes;
diff --git a/src/models/mod.rs b/src/models/mod.rs
index 0b083ff..5cca3ff 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -9,6 +9,19 @@ pub struct Account {
9} 9}
10 10
11#[derive(Debug, Clone, Serialize, Deserialize)] 11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[allow(dead_code)]
13pub struct Mod {
14 pub id: i64,
15 pub instance_id: i64,
16 pub filename: String,
17 pub display_name: Option<String>,
18 pub side: String,
19 pub accepted: bool,
20 pub rejection_reason: Option<String>,
21 pub created_at: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Instance { 25pub struct Instance {
13 pub id: i64, 26 pub id: i64,
14 pub name: String, 27 pub name: String,
@@ -23,6 +36,7 @@ pub struct Instance {
23 pub gamemode: i32, 36 pub gamemode: i32,
24 pub max_players: i32, 37 pub max_players: i32,
25 pub pvp: bool, 38 pub pvp: bool,
39 pub whitelist_enabled: bool,
26 pub motd: String, 40 pub motd: String,
27 pub rcon_password: String, 41 pub rcon_password: String,
28 pub is_active: bool, 42 pub is_active: bool,
diff --git a/src/mods.rs b/src/mods.rs
new file mode 100644
index 0000000..a3f782b
--- /dev/null
+++ b/src/mods.rs
@@ -0,0 +1,480 @@
1//! Mod validation: two-layer inspection of Forge/Fabric mod JARs.
2//!
3//! 1. Metadata check — reads `META-INF/mods.toml` (Forge) or `fabric.mod.json` (Fabric).
4//! 2. Class scan — walks `.class` entries looking for `net/minecraft/server`
5//! vs `net/minecraft/client` references.
6//!
7//! **Rejection policy:** only mods that explicitly declare themselves client-only
8//! in their metadata (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client)
9//! are rejected. The class scan is purely informational — many Forge mods that
10//! work on servers reference `net/minecraft/client/…` for rendering/GUI but do
11//! their server work through common API classes (`net/minecraft/block/Block`, etc.)
12//! without ever referencing `net/minecraft/server/…` directly.
13
14use std::io::{Cursor, Read};
15use zip::ZipArchive;
16
17/// The canonical side for a mod, derived from metadata or class scan.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub enum ModSide {
20 Both,
21 Client,
22 Server,
23 Unknown,
24}
25
26impl std::fmt::Display for ModSide {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 ModSide::Both => write!(f, "BOTH"),
30 ModSide::Client => write!(f, "CLIENT"),
31 ModSide::Server => write!(f, "SERVER"),
32 ModSide::Unknown => write!(f, "UNKNOWN"),
33 }
34 }
35}
36
37/// The result of validating a single mod file.
38#[derive(Debug)]
39pub struct ValidationResult {
40 pub accepted: bool,
41 pub rejection_reason: Option<String>,
42 pub display_name: Option<String>,
43 pub side: ModSide,
44}
45
46/// Validate a mod JAR (raw bytes, filename for error messages).
47///
48/// Returns a `ValidationResult` indicating whether the mod is safe to install.
49///
50/// **Rejection policy:**
51/// - Only mods that explicitly declare themselves client-only **in their metadata**
52/// (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client) are rejected
53/// outright. The class-file scan is purely informational — many Forge mods that
54/// work perfectly on a server reference `net/minecraft/client/…` classes for
55/// rendering/GUI while doing their server logic through common API classes
56/// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever
57/// referencing `net/minecraft/server/…` directly.
58pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult {
59 // Phase 1: metadata check
60 let (meta_side, meta_display_name) = read_metadata(data);
61
62 // Only reject based on metadata — the mod author's explicit declaration.
63 if meta_side == Some(ModSide::Client) {
64 return ValidationResult {
65 accepted: false,
66 rejection_reason: Some(format!(
67 "Mod '{}' declares itself as client-only in its metadata. \
68 It would crash the server.",
69 meta_display_name.as_deref().unwrap_or(filename)
70 )),
71 display_name: meta_display_name,
72 side: ModSide::Client,
73 };
74 }
75
76 // Phase 2: class scan (informational only — never causes rejection)
77 let class_side = scan_jar_for_side(data);
78
79 let side = if meta_side.is_some() {
80 meta_side.unwrap()
81 } else {
82 match class_side {
83 ScanResult::HasServer => ModSide::Both,
84 ScanResult::OnlyClient => ModSide::Client,
85 ScanResult::NoClearSide => ModSide::Unknown,
86 }
87 };
88
89 ValidationResult {
90 accepted: true,
91 rejection_reason: if side == ModSide::Client && meta_side.is_none() {
92 Some("Class scan suggests this mod may be client-side only, \
93 but it has been accepted anyway (no metadata declaration).".to_string())
94 } else {
95 None
96 },
97 display_name: meta_display_name,
98 side,
99 }
100}
101
102// ── Metadata parsing ──────────────────────────────────────────────
103
104/// Read side and display-name from Forge's mods.toml or Fabric's fabric.mod.json.
105/// Returns `(side, display_name)` where side is `None` if no metadata found.
106fn read_metadata(data: &[u8]) -> (Option<ModSide>, Option<String>) {
107 let cursor = Cursor::new(data);
108 let mut archive = match ZipArchive::new(cursor) {
109 Ok(a) => a,
110 Err(_) => return (None, None),
111 };
112
113 // Try Forge mods.toml
114 if let Ok(mut file) = archive.by_name("META-INF/mods.toml") {
115 let mut content = String::new();
116 if file.read_to_string(&mut content).is_ok()
117 && let Some((side, name)) = parse_forge_metadata(&content) {
118 return (Some(side), name);
119 }
120 }
121
122 // Try Fabric fabric.mod.json
123 if let Ok(mut file) = archive.by_name("fabric.mod.json") {
124 let mut content = String::new();
125 if file.read_to_string(&mut content).is_ok()
126 && let Some((side, name)) = parse_fabric_metadata(&content) {
127 return (Some(side), name);
128 }
129 }
130
131 (None, None)
132}
133
134/// Parse `[[mods]]` entries from Forge's mods.toml.
135fn parse_forge_metadata(content: &str) -> Option<(ModSide, Option<String>)> {
136 let value: toml::Value = content.parse().ok()?;
137 let mods = value.get("mods")?.as_array()?;
138
139 let mut has_both = false;
140 let mut has_server = false;
141 let mut has_client = false;
142 let mut display_name = None;
143
144 for entry in mods {
145 let entry = entry.as_table()?;
146 // Use displayName or modId
147 let name = entry
148 .get("displayName")
149 .and_then(|v| v.as_str())
150 .or_else(|| entry.get("modId").and_then(|v| v.as_str()))
151 .map(|s| s.to_string());
152 if display_name.is_none() {
153 display_name = name;
154 }
155
156 match entry.get("side").and_then(|v| v.as_str()) {
157 Some("BOTH") => has_both = true,
158 Some("SERVER") => has_server = true,
159 Some("CLIENT") => has_client = true,
160 _ => {}
161 }
162 }
163
164 if has_both || (has_server && has_client) {
165 Some((ModSide::Both, display_name))
166 } else if has_server {
167 Some((ModSide::Server, display_name))
168 } else if has_client {
169 Some((ModSide::Client, display_name))
170 } else {
171 None
172 }
173}
174
175/// Parse Fabric's fabric.mod.json.
176fn parse_fabric_metadata(content: &str) -> Option<(ModSide, Option<String>)> {
177 let value: serde_json::Value = content.parse().ok()?;
178 let env = value.get("environment").and_then(|v| v.as_str());
179 let name = value
180 .get("name")
181 .and_then(|v| v.as_str())
182 .or_else(|| value.get("id").and_then(|v| v.as_str()))
183 .map(|s| s.to_string());
184
185 match env {
186 Some("*") | Some("server") => Some((ModSide::Both, name)),
187 Some("client") => Some((ModSide::Client, name)),
188 _ => None,
189 }
190}
191
192// ── Class-file scan ───────────────────────────────────────────────
193
194#[derive(Debug, Clone, Copy, PartialEq)]
195enum ScanResult {
196 /// At least one class references server paths (mod is safe).
197 HasServer,
198 /// Every class that references Minecraft packages only references client paths.
199 OnlyClient,
200 /// No class gave a clear indication either way.
201 NoClearSide,
202}
203
204/// Walk all `.class` entries in the JAR and look for client vs server references.
205///
206/// Two independent signal layers:
207/// 1. **Server-content**: `net/minecraft/block/`, `net/minecraft/tileentity/`,
208/// `net/minecraft/command/`, `net/minecraft/world/gen/`, `net/minecraft/entity/ai/`,
209/// `net/minecraft/pathfinding/`, `net/minecraft/server/`.
210/// 2. **Client-API**: `net/minecraft/client/`, `net/minecraftforge/client/`,
211/// `net/minecraftforge/fml/client/`.
212///
213/// The `@Mod` annotation (`net/minecraftforge/fml/common/Mod`) is deliberately
214/// excluded from both sets — it is mandatory boilerplate for EVERY Forge mod
215/// and says nothing about server compatibility.
216fn scan_jar_for_side(data: &[u8]) -> ScanResult {
217 let cursor = Cursor::new(data);
218 let mut archive = match ZipArchive::new(cursor) {
219 Ok(a) => a,
220 Err(_) => return ScanResult::NoClearSide,
221 };
222
223 let mut has_server_content = false;
224 let mut has_client_api = false;
225
226 for i in 0..archive.len() {
227 let mut file = match archive.by_index(i) {
228 Ok(f) => f,
229 Err(_) => continue,
230 };
231
232 let name = file.name().to_string();
233 if !name.ends_with(".class") {
234 continue;
235 }
236
237 let mut bytes = Vec::with_capacity(file.size() as usize);
238 if file.read_to_end(&mut bytes).is_err() {
239 continue;
240 }
241
242 let result = scan_single_class(&bytes);
243 match result {
244 ClassScan::HasServerContent => has_server_content = true,
245 ClassScan::HasClientApi => has_client_api = true,
246 ClassScan::Both => {
247 has_server_content = true;
248 has_client_api = true;
249 }
250 ClassScan::Neither => {}
251 }
252
253 if has_server_content && has_client_api {
254 return ScanResult::HasServer;
255 }
256 }
257
258 if has_server_content {
259 ScanResult::HasServer
260 } else if has_client_api {
261 ScanResult::OnlyClient
262 } else {
263 ScanResult::NoClearSide
264 }
265}
266
267#[derive(Debug, PartialEq)]
268enum ClassScan {
269 /// Found server-side content API refs (`net/minecraft/block/`, `net/minecraft/item/Item`,
270 /// `net/minecraft/tileentity/`, etc.) and/or direct `net/minecraft/server/` refs.
271 HasServerContent,
272 /// Found only client-side API refs (`net/minecraft/client/gui/`,
273 /// `net/minecraftforge/client/`, `net/minecraftforge/fml/client/`) —
274 /// no server or content API refs.
275 HasClientApi,
276 /// Found both server-content AND client-api refs in the same class.
277 Both,
278 /// No Minecraft references at all.
279 Neither,
280}
281
282/// Server-side content API path prefixes — mods referencing these
283/// provide actual world content (blocks, tile entities, commands,
284/// worldgen, AI/pathfinding).
285///
286/// `net/minecraft/item/Item` is deliberately excluded — the Item class is
287/// referenced by both client-only mods (item tooltips, inventory rendering)
288/// and server mods (item registration), so it's not a reliable indicator.
289/// The `ItemStack` substring makes it even worse.
290///
291/// Forge-specific packages (`net/minecraftforge/common/`, etc.) are also
292/// excluded — they are shared client+server APIs referenced by every
293/// Forge mod including purely client-side ones.
294const SERVER_CONTENT_PREFIXES: &[&[u8]] = &[
295 b"net/minecraft/block/",
296 b"net/minecraft/tileentity/",
297 b"net/minecraft/command/",
298 b"net/minecraft/world/gen/",
299 b"net/minecraft/entity/ai/", // entity AI (server-side only)
300 b"net/minecraft/pathfinding/", // pathfinding (server-side only)
301];
302
303/// Client-side API path prefixes — mods referencing ONLY these (without
304/// any SERVER_CONTENT_PREFIXES) are likely client-only.
305///
306/// `net/minecraft/client/` covers everything under the client package
307/// (Minecraft, gui, renderer, resources, settings, network handlers, etc.)
308/// — all are client-only classes that don't exist on the server.
309const CLIENT_API_PREFIXES: &[&[u8]] = &[
310 b"net/minecraft/client/",
311 b"net/minecraftforge/client/",
312 b"net/minecraftforge/fml/client/",
313];
314
315/// Check a single `.class` file's raw bytes for server-side content API vs
316/// client-side API references in the constant pool.
317///
318/// We parse the constant pool minimally to find `CONSTANT_Utf8` entries,
319/// which store the actual string literals (class names, method signatures, etc.)
320/// used by the bytecode. This avoids false positives from raw byte scanning.
321fn scan_single_class(data: &[u8]) -> ClassScan {
322 if data.len() < 10 {
323 return ClassScan::Neither;
324 }
325 if data[0..4] != [0xCA, 0xFE, 0xBA, 0xBE] {
326 return ClassScan::Neither;
327 }
328
329 let cp_count = read_u16_be(&data[8..10]) as usize;
330 if cp_count < 1 {
331 return ClassScan::Neither;
332 }
333
334 let mut pos: usize = 10;
335 let mut has_server = false;
336 let mut has_client = false;
337 let mut i = 1;
338
339 while i < cp_count && pos < data.len() {
340 if pos >= data.len() {
341 break;
342 }
343 let tag = data[pos];
344 pos += 1;
345
346 match tag {
347 1 => {
348 if pos + 2 > data.len() {
349 break;
350 }
351 let len = read_u16_be(&data[pos..pos + 2]) as usize;
352 pos += 2;
353 if pos + len > data.len() {
354 break;
355 }
356 let slice = &data[pos..pos + len];
357
358 if !has_server
359 && ( bytes_contains(slice, b"net/minecraft/server/")
360 || SERVER_CONTENT_PREFIXES.iter().any(|p| bytes_contains(slice, p)))
361 {
362 has_server = true;
363 }
364 if !has_client
365 && CLIENT_API_PREFIXES.iter().any(|p| bytes_contains(slice, p))
366 {
367 has_client = true;
368 }
369 pos += len;
370
371 if has_server && has_client {
372 return ClassScan::Both;
373 }
374 }
375 // CONSTANT_Integer, CONSTANT_Float, CONSTANT_Fieldref,
376 // CONSTANT_Methodref, CONSTANT_InterfaceMethodref,
377 // CONSTANT_Dynamic, CONSTANT_InvokeDynamic
378 3 | 4 | 9 | 10 | 11 | 12 | 17 | 18 => {
379 if pos + 4 > data.len() {
380 break;
381 }
382 pos += 4;
383 }
384 // CONSTANT_Long, CONSTANT_Double (take 2 cp slots)
385 5 | 6 => {
386 if pos + 8 > data.len() {
387 break;
388 }
389 pos += 8;
390 i += 1; // extra slot consumed
391 }
392 // CONSTANT_Class, CONSTANT_String, CONSTANT_MethodType,
393 // CONSTANT_Module, CONSTANT_Package
394 7 | 8 | 16 | 19 | 20 => {
395 if pos + 2 > data.len() {
396 break;
397 }
398 pos += 2;
399 }
400 // CONSTANT_MethodHandle
401 15 => {
402 if pos + 3 > data.len() {
403 break;
404 }
405 pos += 3;
406 }
407 // Unknown/invalid tag — bail to avoid misparse
408 _ => break,
409 }
410 i += 1;
411 }
412
413 if has_server && has_client {
414 ClassScan::Both
415 } else if has_server {
416 ClassScan::HasServerContent
417 } else if has_client {
418 ClassScan::HasClientApi
419 } else {
420 ClassScan::Neither
421 }
422}
423
424// ── Helpers ───────────────────────────────────────────────────────
425
426#[inline]
427fn read_u16_be(bytes: &[u8]) -> u16 {
428 u16::from_be_bytes([bytes[0], bytes[1]])
429}
430
431/// Check if `haystack` contains `needle` at any position.
432#[inline]
433fn bytes_contains(haystack: &[u8], needle: &[u8]) -> bool {
434 haystack.windows(needle.len()).any(|w| w == needle)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440
441 #[test]
442 fn test_read_u16_be() {
443 assert_eq!(read_u16_be(&[0x00, 0x01]), 1);
444 assert_eq!(read_u16_be(&[0x01, 0x00]), 256);
445 assert_eq!(read_u16_be(&[0xFF, 0xFF]), 65535);
446 }
447
448 #[test]
449 fn test_bytes_contains() {
450 assert!(bytes_contains(b"hello world", b"world"));
451 assert!(!bytes_contains(b"hello world", b"foo"));
452 assert!(bytes_contains(b"net/minecraft/server/MinecraftServer", b"net/minecraft/server/"));
453 assert!(bytes_contains(b"net/minecraft/client/Minecraft", b"net/minecraft/client/"));
454 }
455
456 #[test]
457 fn test_invalid_class_file() {
458 assert_eq!(scan_single_class(&[0x00; 4]), ClassScan::Neither);
459 assert_eq!(scan_single_class(&[]), ClassScan::Neither);
460 }
461
462 #[test]
463 fn test_minimal_class_file() {
464 // Valid magic + zero constant pool count — should not crash
465 let data = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x3D, 0x00, 0x00];
466 assert_eq!(scan_single_class(&data), ClassScan::Neither);
467 }
468
469 #[test]
470 fn test_empty_jar() {
471 // Build an empty valid zip
472 let buf = {
473 let mut cursor = Cursor::new(Vec::new());
474 let zip = zip::ZipWriter::new(&mut cursor);
475 zip.finish().unwrap();
476 cursor.into_inner()
477 };
478 assert_eq!(scan_jar_for_side(&buf), ScanResult::NoClearSide);
479 }
480}
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