summaryrefslogtreecommitdiffstats
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
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)
-rw-r--r--CONTEXT.md6
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml1
-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
-rw-r--r--static/style.css492
-rw-r--r--templates/base.html50
-rw-r--r--templates/instances/edit.html349
-rw-r--r--templates/mods/list.html88
18 files changed, 1699 insertions, 385 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
index 21055f5..0fd7b0d 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -14,6 +14,12 @@
14 14
15- **Type** — The server software flavor: `VANILLA`, `FORGE`, or `FABRIC`. Determines which mod loader is installed. Chosen during Instance creation and cannot be changed afterward (changing Type means creating a new Instance). 15- **Type** — The server software flavor: `VANILLA`, `FORGE`, or `FABRIC`. Determines which mod loader is installed. Chosen during Instance creation and cannot be changed afterward (changing Type means creating a new Instance).
16 16
17- **Mod** — A Forge or Fabric mod packaged as a `.jar` file (or nested inside a `.zip` archive) that extends the Minecraft server with new features. Uploaded per-Instance into `data/mods/`.
18
19- **Mod Validation** — The two-layer inspection applied to every uploaded Mod before it is written to disk: (1) metadata check — reads `META-INF/mods.toml` (Forge) or `fabric.mod.json` (Fabric) for the declared side; (2) class scan — walks all `.class` entries in the jar looking for references to `net/minecraft/server` vs `net/minecraft/client`. A Mod is **accepted** if it contains any server-class reference (with or without client classes). It is **rejected** if every class references only the client side.
20
21- **Client-side-only Mod** — A Mod that contains zero server-side classes. These are rejected because they would crash the Minecraft server with a `ClassNotFoundException` or linkage error.
22
17- **Modpack** — An optional set of server-side mods packaged as a ZIP. Applied on top of a World + Version + Type to produce a modded Server Instance. 23- **Modpack** — An optional set of server-side mods packaged as a ZIP. Applied on top of a World + Version + Type to produce a modded Server Instance.
18 24
19- **Backup** — A full snapshot of an Instance's entire `/data` directory at a point in time (world, server properties, mods, whitelist — everything). Stored as a ZIP archive inside the Server Directory. Restoring a Backup creates a new Server Instance; it does not overwrite the original. 25- **Backup** — A full snapshot of an Instance's entire `/data` directory at a point in time (world, server properties, mods, whitelist — everything). Stored as a ZIP archive inside the Server Directory. Restoring a Backup creates a new Server Instance; it does not overwrite the original.
diff --git a/Cargo.lock b/Cargo.lock
index c36739d..dee24f4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1447,6 +1447,7 @@ dependencies = [
1447 "chrono", 1447 "chrono",
1448 "cookie", 1448 "cookie",
1449 "futures-util", 1449 "futures-util",
1450 "multer",
1450 "rand 0.8.7", 1451 "rand 0.8.7",
1451 "reqwest", 1452 "reqwest",
1452 "rusqlite", 1453 "rusqlite",
diff --git a/Cargo.toml b/Cargo.toml
index 828d45d..c0751fd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,6 +5,7 @@ edition = "2024"
5 5
6[dependencies] 6[dependencies]
7axum = { version = "0.8.9", features = ["macros", "multipart"] } 7axum = { version = "0.8.9", features = ["macros", "multipart"] }
8multer = "3.1.0"
8tokio = { version = "1.52.3", features = ["full"] } 9tokio = { version = "1.52.3", features = ["full"] }
9askama = "0.16" 10askama = "0.16"
10rusqlite = { version = "0.40.1", features = ["bundled"] } 11rusqlite = { version = "0.40.1", features = ["bundled"] }
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
diff --git a/static/style.css b/static/style.css
index 61046b2..583b9fb 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1,424 +1,454 @@
1*, 1*,
2*::before, 2*::before,
3*::after { 3*::after {
4 box-sizing: border-box; 4 box-sizing: border-box;
5 margin: 0; 5 margin: 0;
6 padding: 0; 6 padding: 0;
7} 7}
8 8
9:root { 9:root {
10 --bg: #1a1b1e; 10 --bg: #1a1b1e;
11 --bg-card: #25262b; 11 --bg-card: #25262b;
12 --bg-input: #2c2e33; 12 --bg-input: #2c2e33;
13 --fg: #c1c2c5; 13 --fg: #c1c2c5;
14 --fg-muted: #909296; 14 --fg-muted: #909296;
15 --accent: #4c9aff; 15 --accent: #4c9aff;
16 --ok: #51cf66; 16 --ok: #51cf66;
17 --warn: #fcc419; 17 --warn: #fcc419;
18 --err: #ff6b6b; 18 --err: #ff6b6b;
19 --border: #373a40; 19 --border: #373a40;
20 --radius: 8px; 20 --radius: 8px;
21} 21}
22 22
23body { 23body {
24 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 24 font-family:
25 background: var(--bg); 25 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
26 color: var(--fg); 26 background: var(--bg);
27 line-height: 1.5; 27 color: var(--fg);
28 min-height: 100vh; 28 line-height: 1.5;
29 min-height: 100vh;
29} 30}
30 31
31/* Navigation */ 32/* Navigation */
32nav { 33nav {
33 display: flex; 34 display: flex;
34 align-items: center; 35 align-items: center;
35 justify-content: space-between; 36 justify-content: space-between;
36 padding: 0.75rem 1.5rem; 37 padding: 0.75rem 1.5rem;
37 background: var(--bg-card); 38 background: var(--bg-card);
38 border-bottom: 1px solid var(--border); 39 border-bottom: 1px solid var(--border);
39 flex-wrap: wrap; 40 flex-wrap: wrap;
40 gap: 0.5rem; 41 gap: 0.5rem;
41} 42}
42 43
43nav .brand { 44nav .brand {
44 font-weight: 700; 45 font-weight: 700;
45 font-size: 1.1rem; 46 font-size: 1.1rem;
46 color: var(--accent); 47 color: var(--accent);
47} 48}
48 49
49nav .nav-links { 50nav .nav-links {
50 display: flex; 51 display: flex;
51 align-items: center; 52 align-items: center;
52 gap: 1rem; 53 gap: 1rem;
53 flex-wrap: wrap; 54 flex-wrap: wrap;
54} 55}
55 56
56nav a { 57nav a {
57 color: var(--fg); 58 color: var(--fg);
58 text-decoration: none; 59 text-decoration: none;
59 font-size: 0.9rem; 60 font-size: 0.9rem;
60} 61}
61 62
62nav a:hover { 63nav a:hover {
63 color: var(--accent); 64 color: var(--accent);
64} 65}
65 66
66nav .nav-user { 67nav .nav-user {
67 color: var(--fg-muted); 68 color: var(--fg-muted);
68 font-size: 0.85rem; 69 font-size: 0.85rem;
69} 70}
70 71
71/* Buttons */ 72/* Buttons */
72.btn, button { 73.btn,
73 display: inline-block; 74button {
74 padding: 0.5rem 1rem; 75 display: inline-block;
75 border: 1px solid var(--border); 76 padding: 0.5rem 1rem;
76 border-radius: var(--radius); 77 border: 1px solid var(--border);
77 background: var(--bg-input); 78 border-radius: var(--radius);
78 color: var(--fg); 79 background: var(--bg-input);
79 cursor: pointer; 80 color: var(--fg);
80 font-size: 0.9rem; 81 cursor: pointer;
81 text-decoration: none; 82 font-size: 0.9rem;
82 transition: background 0.15s; 83 text-decoration: none;
84 transition: background 0.15s;
83} 85}
84 86
85.btn:hover, button:hover { 87.btn:hover,
86 background: var(--border); 88button:hover {
89 background: var(--border);
87} 90}
88 91
89.btn-ok, button.btn-ok { 92.btn-ok,
90 background: var(--ok); 93button.btn-ok {
91 color: #000; 94 background: var(--ok);
92 border-color: var(--ok); 95 color: #000;
96 border-color: var(--ok);
93} 97}
94 98
95.btn-ok:hover { 99.btn-ok:hover {
96 opacity: 0.85; 100 opacity: 0.85;
97} 101}
98 102
99.btn-danger, button.btn-danger { 103.btn-danger,
100 background: var(--err); 104button.btn-danger {
101 color: #000; 105 background: var(--err);
102 border-color: var(--err); 106 color: #000;
107 border-color: var(--err);
103} 108}
104 109
105.btn-danger:hover { 110.btn-danger:hover {
106 opacity: 0.85; 111 opacity: 0.85;
107} 112}
108 113
109.btn-sm { 114.btn-sm {
110 padding: 0.25rem 0.6rem; 115 padding: 0.25rem 0.6rem;
111 font-size: 0.8rem; 116 font-size: 0.8rem;
112} 117}
113 118
114.btn-link { 119.btn-link {
115 background: none; 120 background: none;
116 border: none; 121 border: none;
117 color: var(--fg-muted); 122 color: var(--fg-muted);
118 cursor: pointer; 123 cursor: pointer;
119 padding: 0; 124 padding: 0;
120} 125}
121 126
122.btn-link:hover { 127.btn-link:hover {
123 color: var(--err); 128 color: var(--err);
124} 129}
125 130
126/* Cards */ 131/* Cards */
127.card { 132.card {
128 background: var(--bg-card); 133 background: var(--bg-card);
129 border: 1px solid var(--border); 134 border: 1px solid var(--border);
130 border-radius: var(--radius); 135 border-radius: var(--radius);
131 padding: 1.25rem; 136 padding: 1.25rem;
132} 137}
133 138
134.card-ok { 139.card-ok {
135 border-color: var(--ok); 140 border-color: var(--ok);
136} 141}
137 142
138.card-err { 143.card-err {
139 border-color: var(--err); 144 border-color: var(--err);
140} 145}
141 146
142.card-muted { 147.card-muted {
143 opacity: 0.7; 148 opacity: 0.7;
144} 149}
145 150
146.card-active { 151.card-active {
147 border-color: var(--accent); 152 border-color: var(--accent);
148 position: relative; 153 position: relative;
149} 154}
150 155
151.active-badge { 156.active-badge {
152 position: absolute; 157 position: absolute;
153 top: -8px; 158 top: -8px;
154 right: -8px; 159 right: -8px;
155 background: var(--accent); 160 background: var(--accent);
156 color: #000; 161 color: #000;
157 font-size: 0.7rem; 162 font-size: 0.7rem;
158 font-weight: 700; 163 font-weight: 700;
159 padding: 0.15rem 0.5rem; 164 padding: 0.15rem 0.5rem;
160 border-radius: 100px; 165 border-radius: 100px;
161} 166}
162 167
163/* Forms */ 168/* Forms */
164form.form-col { 169form.form-col {
165 display: flex; 170 display: flex;
166 flex-direction: column; 171 flex-direction: column;
167 gap: 1rem; 172 gap: 1rem;
168} 173}
169 174
170form.form-col:not(.form-wide) { 175form.form-col:not(.form-wide) {
171 max-width: 600px; 176 max-width: 600px;
172} 177}
173 178
174form.form-row { 179form.form-row {
175 display: flex; 180 display: flex;
176 align-items: center; 181 align-items: center;
177 gap: 0.5rem; 182 gap: 0.5rem;
178} 183}
179 184
180label { 185label {
181 display: flex; 186 display: flex;
182 flex-direction: column; 187 flex-direction: column;
183 gap: 0.25rem; 188 gap: 0.25rem;
184 font-size: 0.9rem; 189 font-size: 0.9rem;
185 color: var(--fg-muted); 190 color: var(--fg-muted);
186} 191}
187 192
188label:has(input[type="radio"]), 193label:has(input[type="radio"]),
189label:has(input[type="checkbox"]) { 194label:has(input[type="checkbox"]) {
190 flex-direction: row; 195 flex-direction: row;
191 align-items: center; 196 align-items: center;
192 gap: 0.5rem; 197 gap: 0.5rem;
193 color: var(--fg); 198 color: var(--fg);
194} 199}
195 200
196input, select, textarea { 201input,
197 padding: 0.5rem 0.75rem; 202select,
198 border: 1px solid var(--border); 203textarea {
199 border-radius: var(--radius); 204 padding: 0.5rem 0.75rem;
200 background: var(--bg-input); 205 border: 1px solid var(--border);
201 color: var(--fg); 206 border-radius: var(--radius);
202 font-size: 0.9rem; 207 background: var(--bg-input);
208 color: var(--fg);
209 font-size: 0.9rem;
203} 210}
204 211
205input:focus, select:focus { 212input:focus,
206 outline: none; 213select:focus {
207 border-color: var(--accent); 214 outline: none;
215 border-color: var(--accent);
208} 216}
209 217
210fieldset { 218fieldset {
211 border: 1px solid var(--border); 219 border: 1px solid var(--border);
212 border-radius: var(--radius); 220 border-radius: var(--radius);
213 padding: 1rem; 221 padding: 1rem;
214} 222}
215 223
216legend { 224legend {
217 font-weight: 600; 225 font-weight: 600;
218 padding: 0 0.5rem; 226 padding: 0 0.5rem;
219 color: var(--fg-muted); 227 color: var(--fg-muted);
220 font-size: 0.85rem; 228 font-size: 0.85rem;
221} 229}
222 230
223small { 231small {
224 color: var(--fg-muted); 232 color: var(--fg-muted);
225 font-size: 0.8rem; 233 font-size: 0.8rem;
226} 234}
227 235
228/* Layout */ 236/* Layout */
229main { 237main {
230 max-width: 1000px; 238 max-width: 1000px;
231 margin: 0 auto; 239 margin: 0 auto;
232 padding: 1.5rem; 240 padding: 1.5rem;
233} 241}
234 242
235h1, h2, h3 { 243h1,
236 color: var(--fg); 244h2,
237 margin-bottom: 0.75rem; 245h3 {
246 color: var(--fg);
247 margin-bottom: 0.75rem;
238} 248}
239 249
240.page-header { 250.page-header {
241 display: flex; 251 display: flex;
242 align-items: center; 252 align-items: center;
243 justify-content: space-between; 253 justify-content: space-between;
244 margin-bottom: 1.5rem; 254 margin-bottom: 1.5rem;
245 flex-wrap: wrap; 255 flex-wrap: wrap;
246 gap: 1rem; 256 gap: 1rem;
247} 257}
248 258
249.instance-grid { 259.instance-grid {
250 display: grid; 260 display: grid;
251 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 261 grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
252 gap: 1rem; 262 gap: 1rem;
253} 263}
254 264
255.tab-row { 265.tab-row {
256 display: grid; 266 display: grid;
257 grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); 267 grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
258 gap: 1rem; 268 gap: 1rem;
259} 269}
260 270
261/* Status badges */ 271/* Status badges */
262.status-badge { 272.status-badge {
263 display: inline-block; 273 display: inline-block;
264 padding: 0.25rem 0.75rem; 274 padding: 0.25rem 0.75rem;
265 border-radius: 100px; 275 border-radius: 100px;
266 font-size: 0.85rem; 276 font-size: 0.85rem;
267 font-weight: 600; 277 font-weight: 600;
268} 278}
269 279
270.status-running { 280.status-running {
271 background: var(--ok); 281 background: var(--ok);
272 color: #000; 282 color: #000;
273} 283}
274 284
275.status-starting, .status-booting, .status-pulling { 285.status-starting,
276 background: var(--warn); 286.status-booting,
277 color: #000; 287.status-pulling {
288 background: var(--warn);
289 color: #000;
278} 290}
279 291
280.status-stopped { 292.status-stopped {
281 background: var(--fg-muted); 293 background: var(--fg-muted);
282 color: #000; 294 color: #000;
283} 295}
284 296
285.status-error, .status-not_found { 297.status-warning {
286 background: var(--err); 298 background: var(--warn);
287 color: #000; 299 color: #000;
300}
301
302.status-error,
303.status-not_found {
304 background: var(--err);
305 color: #000;
288} 306}
289 307
290.status-row { 308.status-row {
291 display: flex; 309 display: flex;
292 align-items: center; 310 align-items: center;
293 gap: 1rem; 311 gap: 1rem;
294 margin: 0.75rem 0; 312 margin: 0.75rem 0;
295} 313}
296 314
297/* Tags */ 315/* Tags */
298.tag { 316.tag {
299 display: inline-block; 317 display: inline-block;
300 padding: 0.1rem 0.5rem; 318 padding: 0.1rem 0.5rem;
301 border-radius: 100px; 319 border-radius: 100px;
302 font-size: 0.75rem; 320 font-size: 0.75rem;
303 background: var(--border); 321 background: var(--border);
304 color: var(--fg-muted); 322 color: var(--fg-muted);
305} 323}
306 324
307.tag-admin { 325.tag-admin {
308 background: var(--warn); 326 background: var(--warn);
309 color: #000; 327 color: #000;
328}
329
330.tag-ok {
331 background: var(--ok);
332 color: #000;
333}
334
335.tag-err {
336 background: var(--err);
337 color: #000;
310} 338}
311 339
312/* Instance meta */ 340/* Instance meta */
313.instance-meta, .meta { 341.instance-meta,
314 display: flex; 342.meta {
315 gap: 0.75rem; 343 display: flex;
316 flex-wrap: wrap; 344 gap: 0.75rem;
317 font-size: 0.85rem; 345 flex-wrap: wrap;
318 color: var(--fg-muted); 346 font-size: 0.85rem;
319 margin-bottom: 0.5rem; 347 color: var(--fg-muted);
348 margin-bottom: 0.5rem;
320} 349}
321 350
322.actions { 351.actions {
323 display: flex; 352 display: flex;
324 gap: 0.5rem; 353 gap: 0.5rem;
325 flex-wrap: wrap; 354 flex-wrap: wrap;
326 margin-top: 0.75rem; 355 margin-top: 0.75rem;
327} 356}
328 357
329.players { 358.players {
330 font-size: 0.9rem; 359 font-size: 0.9rem;
331 color: var(--accent); 360 color: var(--accent);
332} 361}
333 362
334/* Player list */ 363/* Player list */
335.player-list { 364.player-list {
336 list-style: none; 365 list-style: none;
337 margin-top: 0.5rem; 366 margin-top: 0.5rem;
338} 367}
339 368
340.player-list li { 369.player-list li {
341 display: flex; 370 display: flex;
342 align-items: center; 371 align-items: center;
343 justify-content: space-between; 372 justify-content: space-between;
344 padding: 0.4rem 0; 373 padding: 0.4rem 0;
345 border-bottom: 1px solid var(--border); 374 border-bottom: 1px solid var(--border);
346 font-size: 0.9rem; 375 font-size: 0.9rem;
347} 376}
348 377
349/* Table */ 378/* Table */
350.table { 379.table {
351 width: 100%; 380 width: 100%;
352 border-collapse: collapse; 381 border-collapse: collapse;
353 font-size: 0.9rem; 382 font-size: 0.9rem;
354} 383}
355 384
356.table th, .table td { 385.table th,
357 text-align: left; 386.table td {
358 padding: 0.5rem; 387 text-align: left;
359 border-bottom: 1px solid var(--border); 388 padding: 0.5rem;
389 border-bottom: 1px solid var(--border);
360} 390}
361 391
362.table th { 392.table th {
363 color: var(--fg-muted); 393 color: var(--fg-muted);
364 font-weight: 600; 394 font-weight: 600;
365} 395}
366 396
367/* Flash messages */ 397/* Flash messages */
368.flash { 398.flash {
369 padding: 0.75rem 1rem; 399 padding: 0.75rem 1rem;
370 border-radius: var(--radius); 400 border-radius: var(--radius);
371 margin-bottom: 1rem; 401 margin-bottom: 1rem;
372 font-size: 0.9rem; 402 font-size: 0.9rem;
373} 403}
374 404
375.flash-err { 405.flash-err {
376 background: color-mix(in srgb, var(--err) 15%, transparent); 406 background: color-mix(in srgb, var(--err) 15%, transparent);
377 border: 1px solid var(--err); 407 border: 1px solid var(--err);
378 color: var(--err); 408 color: var(--err);
379} 409}
380 410
381.flash-ok { 411.flash-ok {
382 background: color-mix(in srgb, var(--ok) 15%, transparent); 412 background: color-mix(in srgb, var(--ok) 15%, transparent);
383 border: 1px solid var(--ok); 413 border: 1px solid var(--ok);
384 color: var(--ok); 414 color: var(--ok);
385} 415}
386 416
387.flash-warn { 417.flash-warn {
388 background: color-mix(in srgb, var(--warn) 15%, transparent); 418 background: color-mix(in srgb, var(--warn) 15%, transparent);
389 border: 1px solid var(--warn); 419 border: 1px solid var(--warn);
390 color: var(--warn); 420 color: var(--warn);
391} 421}
392 422
393/* Login page */ 423/* Login page */
394.login-page { 424.login-page {
395 display: flex; 425 display: flex;
396 flex-direction: column; 426 flex-direction: column;
397 align-items: center; 427 align-items: center;
398 justify-content: center; 428 justify-content: center;
399 min-height: 100vh; 429 min-height: 100vh;
400 max-width: 380px; 430 max-width: 380px;
401} 431}
402 432
403.login-form { 433.login-form {
404 width: 100%; 434 width: 100%;
405 display: flex; 435 display: flex;
406 flex-direction: column; 436 flex-direction: column;
407 gap: 1rem; 437 gap: 1rem;
408} 438}
409 439
410.login-form button { 440.login-form button {
411 margin-top: 0.5rem; 441 margin-top: 0.5rem;
412} 442}
413 443
414/* Responsive */ 444/* Responsive */
415@media (max-width: 600px) { 445@media (max-width: 600px) {
416 nav { 446 nav {
417 flex-direction: column; 447 flex-direction: column;
418 align-items: flex-start; 448 align-items: flex-start;
419 } 449 }
420 450
421 .instance-grid { 451 .instance-grid {
422 grid-template-columns: 1fr; 452 grid-template-columns: 1fr;
423 } 453 }
424} 454}
diff --git a/templates/base.html b/templates/base.html
index 3a92cd0..0af5d57 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -1,37 +1,35 @@
1<!DOCTYPE html> 1<!doctype html>
2<html lang="en"> 2<html lang="en">
3<head> 3 <head>
4 <meta charset="UTF-8"> 4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Minecraft UI</title> 6 <title>Minecraft UI</title>
7 <script src="/static/htmx.min.js"></script> 7 <script src="/static/htmx.min.js"></script>
8 <link rel="stylesheet" href="/static/style.css"> 8 <link rel="stylesheet" href="/static/style.css" />
9</head> 9 </head>
10<body hx-boost="true"> 10 <body>
11 <nav> 11 <nav hx-boost="true">
12 <span class="brand">Minecraft UI</span> 12 <span class="brand">Minecraft UI</span>
13 <div class="nav-links"> 13 <div class="nav-links">
14 <a href="/">Dashboard</a> 14 <a href="/">Dashboard</a>
15 <a href="/instances">Instances</a> 15 <a href="/instances">Instances</a>
16 <a href="/players">Players</a> 16 <a href="/players">Players</a>
17 {% if is_superuser %} 17 {% if is_superuser %}
18 <a href="/accounts">Accounts</a> 18 <a href="/accounts">Accounts</a>
19 {% endif %} 19 {% endif %}
20 <span class="nav-user">{{ username }}</span> 20 <span class="nav-user">{{ username }}</span>
21 <form action="/logout" method="post" style="display:inline"> 21 <form action="/logout" method="post" style="display: inline">
22 <button type="submit" class="btn-link">Logout</button> 22 <button type="submit" class="btn-link">Logout</button>
23 </form> 23 </form>
24 </div> 24 </div>
25 </nav> 25 </nav>
26 26
27 {% if password_change_required %} 27 {% if password_change_required %}
28 <div class="flash flash-warn"> 28 <div class="flash flash-warn">
29 You must change your password before continuing. 29 You must change your password before continuing.
30 </div> 30 </div>
31 {% endif %} 31 {% endif %}
32 32
33 <main> 33 <main>{% block content %}{% endblock %}</main>
34 {% block content %}{% endblock %} 34 </body>
35 </main>
36</body>
37</html> 35</html>
diff --git a/templates/instances/edit.html b/templates/instances/edit.html
index fdfc0e1..2660461 100644
--- a/templates/instances/edit.html
+++ b/templates/instances/edit.html
@@ -1,94 +1,287 @@
1{% extends "base.html" %} 1{% extends "base.html" %} {% block content %}
2{% block content %}
3<h1>Edit: {{ instance.name }}</h1> 2<h1>Edit: {{ instance.name }}</h1>
4 3
5<div class="card card-muted"> 4<div class="card card-muted">
6 <div class="meta"> 5 <div class="meta">
7 <span><strong>Version:</strong> {{ instance.version }}</span> 6 <span><strong>Version:</strong> {{ instance.version }}</span>
8 <span><strong>Type:</strong> {{ instance.server_type }}</span> 7 <span><strong>Type:</strong> {{ instance.server_type }}</span>
9 <span><strong>World:</strong> {{ instance.world_source }}</span> 8 <span><strong>World:</strong> {{ instance.world_source }}</span>
10 {% if let Some(seed) = current_seed %} 9 {% if let Some(seed) = current_seed %}
11 <span><strong>Seed:</strong> {{ seed }}</span> 10 <span><strong>Seed:</strong> {{ seed }}</span>
12 {% endif %} 11 {% endif %} {% if instance.has_modpack %}
13 {% if instance.has_modpack %} 12 <span><strong>Modpack:</strong> Installed</span>
14 <span><strong>Modpack:</strong> Installed</span> 13 {% endif %}
15 {% endif %} 14 </div>
16 </div>
17</div> 15</div>
18 16
19<form action="/instances/{{ instance.id }}/edit" method="post" class="form-col"> 17<form action="/instances/{{ instance.id }}/edit" method="post" class="form-col">
20 <fieldset> 18 <fieldset>
21 <legend>Server Properties</legend> 19 <legend>Server Properties</legend>
22 <label> 20 <label>
23 Memory (MB) 21 Memory (MB)
24 <input type="number" name="memory_mb" value="{{ instance.memory_mb }}" min="512" max="{{ max_memory_mb }}" required> 22 <input
25 </label> 23 type="number"
26 <label> 24 name="memory_mb"
27 Difficulty 25 value="{{ instance.memory_mb }}"
28 <select name="difficulty"> 26 min="512"
29 <option value="0" {% if instance.difficulty == 0 %}selected{% endif %}>Peaceful</option> 27 max="{{ max_memory_mb }}"
30 <option value="1" {% if instance.difficulty == 1 %}selected{% endif %}>Easy</option> 28 required
31 <option value="2" {% if instance.difficulty == 2 %}selected{% endif %}>Normal</option> 29 />
32 <option value="3" {% if instance.difficulty == 3 %}selected{% endif %}>Hard</option> 30 </label>
33 </select> 31 <label>
34 </label> 32 Difficulty
35 <label> 33 <select name="difficulty">
36 Gamemode 34 <option
37 <select name="gamemode"> 35 value="0"
38 <option value="0" {% if instance.gamemode == 0 %}selected{% endif %}>Survival</option> 36 {%
39 <option value="1" {% if instance.gamemode == 1 %}selected{% endif %}>Creative</option> 37 if
40 <option value="2" {% if instance.gamemode == 2 %}selected{% endif %}>Adventure</option> 38 instance.difficulty=""
41 <option value="3" {% if instance.gamemode == 3 %}selected{% endif %}>Spectator</option> 39 ="0"
42 </select> 40 %}selected{%
43 </label> 41 endif
44 <label> 42 %}
45 Max Players 43 >
46 <input type="number" name="max_players" value="{{ instance.max_players }}" min="1" max="100"> 44 Peaceful
47 </label> 45 </option>
48 <label> 46 <option
49 <input type="checkbox" name="pvp" {% if instance.pvp %}checked{% endif %}> PvP enabled 47 value="1"
50 </label> 48 {%
51 <label> 49 if
52 MOTD 50 instance.difficulty=""
53 <input type="text" name="motd" value="{{ instance.motd }}" maxlength="59"> 51 ="1"
54 </label> 52 %}selected{%
55 </fieldset> 53 endif
54 %}
55 >
56 Easy
57 </option>
58 <option
59 value="2"
60 {%
61 if
62 instance.difficulty=""
63 ="2"
64 %}selected{%
65 endif
66 %}
67 >
68 Normal
69 </option>
70 <option
71 value="3"
72 {%
73 if
74 instance.difficulty=""
75 ="3"
76 %}selected{%
77 endif
78 %}
79 >
80 Hard
81 </option>
82 </select>
83 </label>
84 <label>
85 Gamemode
86 <select name="gamemode">
87 <option value="0" {% if instance.gamemode="" ="0" %}selected{% endif %}>
88 Survival
89 </option>
90 <option value="1" {% if instance.gamemode="" ="1" %}selected{% endif %}>
91 Creative
92 </option>
93 <option value="2" {% if instance.gamemode="" ="2" %}selected{% endif %}>
94 Adventure
95 </option>
96 <option value="3" {% if instance.gamemode="" ="3" %}selected{% endif %}>
97 Spectator
98 </option>
99 </select>
100 </label>
101 <label>
102 Max Players
103 <input
104 type="number"
105 name="max_players"
106 value="{{ instance.max_players }}"
107 min="1"
108 max="100"
109 />
110 </label>
111 <label>
112 <input
113 type="checkbox"
114 name="pvp"
115 {%
116 if
117 instance.pvp
118 %}checked{%
119 endif
120 %}
121 />
122 PvP enabled
123 </label>
124 <label>
125 <input
126 type="checkbox"
127 name="whitelist_enabled"
128 {%
129 if
130 instance.whitelist_enabled
131 %}checked{%
132 endif
133 %}
134 />
135 Whitelist enabled
136 </label>
137 <label>
138 MOTD
139 <input
140 type="text"
141 name="motd"
142 value="{{ instance.motd }}"
143 maxlength="59"
144 />
145 </label>
146 </fieldset>
56 147
57 <div class="actions"> 148 <div class="actions">
58 <button type="submit" class="btn-ok">Save Changes</button> 149 <button type="submit" class="btn-ok">Save Changes</button>
59 <a href="/instances" class="btn">Cancel</a> 150 <a href="/instances" class="btn">Cancel</a>
60 </div> 151 </div>
61</form> 152</form>
62 153
63<div class="card{% if instance.is_active %} card-muted{% endif %}" style="margin-top:1.5rem"> 154<div
64 <h3>World Management</h3> 155 class="card{% if instance.is_active %} card-muted{% endif %}"
156 style="margin-top: 1.5rem"
157>
158 <h3>World Management</h3>
65 159
66 {% if instance.is_active %} 160 {% if instance.is_active %}
67 <div class="flash flash-warn">Stop the server before managing the world.</div> 161 <div class="flash flash-warn">Stop the server before managing the world.</div>
68 {% endif %} 162 {% endif %}
69 163
70 <div class="meta" style="margin-bottom:1rem"> 164 <div class="meta" style="margin-bottom: 1rem">
71 <span><strong>Current seed:</strong> 165 <span
72 {% if let Some(seed) = current_seed %} 166 ><strong>Current seed:</strong>
73 {{ seed }} 167 {% if let Some(seed) = current_seed %} {{ seed }} {% else %}
74 {% else %} 168 <em>not yet generated</em>
75 <em>not yet generated</em> 169 {% endif %}
76 {% endif %} 170 </span>
77 </span> 171 </div>
78 </div>
79 172
80 <div class="actions" style="flex-wrap:wrap; gap:1rem; {% if instance.is_active %}pointer-events:none; opacity:0.5{% endif %}"> 173 <div
81 <a href="/instances/{{ instance.id }}/world/export" class="btn" hx-boost="false"{% if instance.is_active %} tabindex="-1"{% endif %}>Export World</a> 174 class="actions"
175 style="flex-wrap:wrap; gap:1rem; {% if instance.is_active %}pointer-events:none; opacity:0.5{% endif %}"
176 >
177 <a
178 href="/instances/{{ instance.id }}/world/export"
179 class="btn"
180 hx-boost="false"
181 {%
182 if
183 instance.is_active
184 %}
185 tabindex="-1"
186 {%
187 endif
188 %}
189 >Export World</a
190 >
82 191
83 <form action="/instances/{{ instance.id }}/world/import" method="post" enctype="multipart/form-data" style="display:flex;align-items:center;gap:0.5rem" hx-boost="false"> 192 <form
84 <input type="file" name="world_zip" accept=".zip" required style="font-size:0.85rem"{% if instance.is_active %} disabled{% endif %}> 193 action="/instances/{{ instance.id }}/world/import"
85 <button type="submit" class="btn" onclick="return confirm('This will replace the current world. Continue?')"{% if instance.is_active %} disabled{% endif %}>Import</button> 194 method="post"
86 </form> 195 enctype="multipart/form-data"
196 style="display: flex; align-items: center; gap: 0.5rem"
197 hx-boost="false"
198 >
199 <input
200 type="file"
201 name="world_zip"
202 accept=".zip"
203 required
204 style="font-size: 0.85rem"
205 {%
206 if
207 instance.is_active
208 %}
209 disabled{%
210 endif
211 %}
212 />
213 <button
214 type="submit"
215 class="btn"
216 onclick="
217 return confirm('This will replace the current world. Continue?');
218 "
219 {%
220 if
221 instance.is_active
222 %}
223 disabled{%
224 endif
225 %}
226 >
227 Import
228 </button>
229 </form>
230
231 <form
232 action="/instances/{{ instance.id }}/world/regenerate"
233 method="post"
234 class="form-row"
235 onsubmit="
236 return confirm(
237 'This will DELETE the current world and generate a new one. Continue?',
238 );
239 "
240 >
241 <input
242 type="text"
243 name="seed"
244 placeholder="Seed (empty = random)"
245 style="width: 180px"
246 {%
247 if
248 instance.is_active
249 %}
250 disabled{%
251 endif
252 %}
253 />
254 <button
255 type="submit"
256 class="btn-danger"
257 {%
258 if
259 instance.is_active
260 %}
261 disabled{%
262 endif
263 %}
264 >
265 Regenerate
266 </button>
267 </form>
268 </div>
269</div>
87 270
88 <form action="/instances/{{ instance.id }}/world/regenerate" method="post" class="form-row" onsubmit="return confirm('This will DELETE the current world and generate a new one. Continue?')"> 271<div class="card" style="margin-top: 1.5rem">
89 <input type="text" name="seed" placeholder="Seed (empty = random)" style="width:180px"{% if instance.is_active %} disabled{% endif %}> 272 <h3>Mods</h3>
90 <button type="submit" class="btn-danger"{% if instance.is_active %} disabled{% endif %}>Regenerate</button> 273 <div class="meta" style="margin-bottom: 0.5rem">
91 </form> 274 <span>
92 </div> 275 {% if instance.has_modpack %}
276 <strong>{{ mod_count }}</strong> mod{% if mod_count != 1 %}s{% endif %}
277 installed {% if rejected_count > 0 %} (<strong
278 >{{ rejected_count }}</strong
279 >
280 rejected) {% endif %} {% else %} No mods installed {% endif %}
281 </span>
282 </div>
283 <div class="actions">
284 <a href="/instances/{{ instance.id }}/mods" class="btn">Manage Mods</a>
285 </div>
93</div> 286</div>
94{% endblock %} 287{% endblock %}
diff --git a/templates/mods/list.html b/templates/mods/list.html
new file mode 100644
index 0000000..b8aff1f
--- /dev/null
+++ b/templates/mods/list.html
@@ -0,0 +1,88 @@
1{% extends "base.html" %}
2{% block content %}
3<div class="page-header">
4 <h1>Mods: {{ instance_name }}</h1>
5 <a href="/instances/{{ instance_id }}/edit" class="btn">&larr; Back to Instance</a>
6</div>
7
8{% if let Some(msg) = flash %}
9<div class="flash flash-{{ msg.kind }}">{{ msg.text }}</div>
10{% endif %}
11
12<div class="card" style="margin-bottom:1rem">
13 <form action="/instances/{{ instance_id }}/mods" method="post" enctype="multipart/form-data" class="form-row">
14 <input type="file" name="mod_file" accept=".jar,.zip" required style="font-size:0.85rem">
15 <button type="submit" class="btn-ok">Upload & Validate</button>
16 </form>
17 <p style="margin-top:0.5rem;font-size:0.8rem;color:var(--fg-muted)">
18 Upload <code>.jar</code> files directly or a <code>.zip</code> containing multiple <code>.jar</code> files.
19 Mods containing <strong>only</strong> client-side classes will be rejected.
20 </p>
21</div>
22
23{% if mods.is_empty() %}
24<div class="card card-muted">
25 <p>No mods installed. Upload a mod to get started.</p>
26</div>
27{% else %}
28<table class="table">
29 <thead>
30 <tr>
31 <th>Name</th>
32 <th>Filename</th>
33 <th>Side</th>
34 <th>Status</th>
35 <th>Added</th>
36 <th></th>
37 </tr>
38 </thead>
39 <tbody>
40 {% for m in mods %}
41 <tr>
42 <td>
43 {% if let Some(name) = m.display_name %}
44 {{ name }}
45 {% else %}
46 <em>{{ m.filename }}</em>
47 {% endif %}
48 </td>
49 <td><code>{{ m.filename }}</code></td>
50 <td>
51 <span class="tag
52 {% if m.side == "BOTH" %}tag-ok{% endif %}
53 {% if m.side == "SERVER" %}tag-ok{% endif %}
54 {% if m.side == "CLIENT" %}tag-err{% endif %}
55 {% if m.side == "UNKNOWN" %}tag{% endif %}
56 ">{{ m.side }}</span>
57 </td>
58 <td>
59 {% if !m.accepted %}
60 <span class="status-badge status-error">Rejected</span>
61 {% else if m.side == "CLIENT" %}
62 <span class="status-badge status-warning">Warning</span>
63 {% else %}
64 <span class="status-badge status-running">Accepted</span>
65 {% endif %}
66 {% if let Some(reason) = m.rejection_reason %}
67 <div style="font-size:0.8rem;color:var(--err);margin-top:0.25rem">{{ reason }}</div>
68 {% endif %}
69 </td>
70 <td>{{ m.created_at }}</td>
71 <td>
72 <form action="/instances/{{ instance_id }}/mods/{{ m.id }}/delete" method="post" style="display:inline"
73 onsubmit="return confirm('Remove this mod?')">
74 <button type="submit" class="btn-danger btn-sm">Remove</button>
75 </form>
76 </td>
77 </tr>
78 {% endfor %}
79 </tbody>
80</table>
81{% endif %}
82
83<div style="margin-top:1rem">
84 <form action="/instances/{{ instance_id }}/mods/rescan" method="post" style="display:inline">
85 <button type="submit" class="btn">Rescan All Mods</button>
86 </form>
87</div>
88{% endblock %}