From 74d2ada067bd2fd9436710ab7ac260e0eddf1314 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Jul 2026 01:03:24 +0200 Subject: 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 (was corrupting multipart uploads) --- CONTEXT.md | 6 + Cargo.lock | 1 + Cargo.toml | 1 + src/auth.rs | 27 ++- src/db.rs | 16 ++ src/docker.rs | 61 ++++-- src/main.rs | 1 + src/models/mod.rs | 14 ++ src/mods.rs | 480 +++++++++++++++++++++++++++++++++++++++++ src/routes/backups.rs | 8 +- src/routes/instances.rs | 61 ++++-- src/routes/mod.rs | 2 + src/routes/mods.rs | 422 ++++++++++++++++++++++++++++++++++++ src/routes/players.rs | 5 +- static/style.css | 492 ++++++++++++++++++++++-------------------- templates/base.html | 50 +++-- templates/instances/edit.html | 349 +++++++++++++++++++++++------- templates/mods/list.html | 88 ++++++++ 18 files changed, 1699 insertions(+), 385 deletions(-) create mode 100644 src/mods.rs create mode 100644 src/routes/mods.rs create mode 100644 templates/mods/list.html diff --git a/CONTEXT.md b/CONTEXT.md index 21055f5..0fd7b0d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,6 +14,12 @@ - **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). +- **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/`. + +- **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. + +- **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. + - **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. - **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 = [ "chrono", "cookie", "futures-util", + "multer", "rand 0.8.7", "reqwest", "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" [dependencies] axum = { version = "0.8.9", features = ["macros", "multipart"] } +multer = "3.1.0" tokio = { version = "1.52.3", features = ["full"] } askama = "0.16" rusqlite = { 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( mut request: Request, next: Next, ) -> Result { - let cookies = request + let raw_cookies = request .headers() .get(axum::http::header::COOKIE) .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + .unwrap_or("[no cookie header]") + .to_string(); - let session_id = cookies.split(';').map(|s| s.trim()).find_map(|cookie| { + let session_id = raw_cookies.split(';').map(|s| s.trim()).find_map(|cookie| { if let Some((name, value)) = cookie.split_once('=') { - if name.trim() == SESSION_COOKIE { + let n = name.trim(); + if n == SESSION_COOKIE { Some(value.trim().to_string()) } else { None @@ -137,13 +139,18 @@ pub async fn require_auth( .ok(); if let Some(account) = account { + tracing::debug!("Auth OK for {}", account.username); request.extensions_mut().insert(account); Ok(next.run(request).await) } else { + tracing::warn!("Session {id} not found in DB (raw cookies: {raw_cookies})"); Err(Redirect::to("/login").into_response()) } } - None => Err(Redirect::to("/login").into_response()), + None => { + tracing::warn!("No session cookie found (raw cookies: {raw_cookies})"); + Err(Redirect::to("/login").into_response()) + } } } @@ -170,21 +177,19 @@ pub async fn logout( State(state): State, headers: axum::http::HeaderMap, ) -> impl IntoResponse { - if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) { - if let Ok(cookie_str) = cookie_header.to_str() { + if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) + && let Ok(cookie_str) = cookie_header.to_str() { for part in cookie_str.split(';') { let part = part.trim(); - if let Some((name, value)) = part.split_once('=') { - if name.trim() == SESSION_COOKIE { + if let Some((name, value)) = part.split_once('=') + && name.trim() == SESSION_COOKIE { let _ = state .db .conn() .execute("DELETE FROM sessions WHERE id = ?1", params![value.trim()]); } - } } } - } let clear_cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0"); ( 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 { updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS mods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id INTEGER NOT NULL, + filename TEXT NOT NULL, + display_name TEXT, + side TEXT NOT NULL DEFAULT 'UNKNOWN', + accepted INTEGER NOT NULL DEFAULT 0, + rejection_reason TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, @@ -98,6 +110,10 @@ impl Database { let _ = conn.execute_batch( "ALTER TABLE instances ADD COLUMN container_state TEXT NOT NULL DEFAULT 'stopped'", ); + // Migration: add whitelist_enabled column if missing + let _ = conn.execute_batch( + "ALTER TABLE instances ADD COLUMN whitelist_enabled INTEGER NOT NULL DEFAULT 1", + ); Ok(()) } 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 { Ok(Self { client, config }) } + /// Map a Minecraft version string (e.g. "1.12.2", "1.20.1") to the + /// appropriate itzg/minecraft-server Java image tag. + /// + /// - < 1.17 → java8 (Minecraft 1.12.2 and earlier) + /// - 1.17 .. 1.20.4 → java17 + /// - >= 1.20.5 → java21 + fn java_tag(version: &str) -> &'static str { + // Parse major.minor from version strings like "1.12.2", "1.20.1", "1.21" + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() < 2 { + return "java17"; // sensible default + } + let major: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(1); + let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(12); + + if major <= 1 && minor < 17 { + "java8" + } else if major <= 1 && minor < 21 { + // 1.17.x through 1.20.4 use Java 17 + "java17" + } else { + // 1.20.5+ and 1.21+ use Java 21 + "java21" + } + } + + /// Build the full image reference, e.g. "itzg/minecraft-server:java8" + fn image_ref(version: &str) -> String { + format!("itzg/minecraft-server:{}", Self::java_tag(version)) + } + pub async fn start_container(&self, instance: &Instance) -> Result<(), String> { - tracing::info!("Pulling itzg/minecraft-server:latest image..."); - self.pull_image().await?; + let tag = Self::java_tag(&instance.version); + tracing::info!("Pulling itzg/minecraft-server:{tag} image..."); + self.pull_image(tag).await?; tracing::info!("Creating container for instance '{}'", instance.name); self.recreate_container(instance).await?; tracing::info!("Starting container..."); self.start_existing().await } - async fn pull_image(&self) -> Result<(), String> { + async fn pull_image(&self, tag: &str) -> Result<(), String> { let options = CreateImageOptions { from_image: "itzg/minecraft-server", - tag: "latest", + tag, ..Default::default() }; @@ -74,6 +106,8 @@ impl DockerManager { .to_string(); let instance_data = format!("{server_dir}/instances/{}/data", instance.name); + let image_ref = Self::image_ref(&instance.version); + let mut env_strings: Vec = vec![ "EULA=TRUE".into(), format!("VERSION={}", instance.version), @@ -83,7 +117,7 @@ impl DockerManager { format!("DIFFICULTY={}", instance.difficulty), "FORCE_GAMEMODE=true".into(), "SPAWN_PROTECTION=0".into(), - "ENABLE_WHITELIST=true".into(), + format!("ENABLE_WHITELIST={}", instance.whitelist_enabled), "PAUSE_WHEN_EMPTY_SECONDS=30".into(), "ONLINE_MODE=false".into(), "TZ=Europe/Warsaw".into(), @@ -102,17 +136,14 @@ impl DockerManager { env_strings.push("PVP=false".into()); } - if let Some(ref seed) = instance.world_seed { - if !seed.is_empty() { + if let Some(ref seed) = instance.world_seed + && !seed.is_empty() { env_strings.push(format!("LEVEL_SEED={seed}")); } - } - - let env_refs: Vec<&str> = env_strings.iter().map(|s| s.as_str()).collect(); - let mut exposed_ports: HashMap<&str, HashMap<(), ()>> = HashMap::new(); - exposed_ports.insert("25565/tcp", HashMap::new()); - exposed_ports.insert("25575/tcp", HashMap::new()); + let mut exposed_ports: HashMap> = HashMap::new(); + exposed_ports.insert("25565/tcp".into(), HashMap::new()); + exposed_ports.insert("25575/tcp".into(), HashMap::new()); let mut port_bindings = HashMap::new(); port_bindings.insert( @@ -137,9 +168,9 @@ impl DockerManager { }; let container_config = ContainerConfig { - image: Some("itzg/minecraft-server:latest"), + image: Some(image_ref.clone()), tty: Some(true), - env: Some(env_refs), + env: Some(env_strings.clone()), exposed_ports: Some(exposed_ports), host_config: Some(host_config), ..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; mod db; mod docker; mod models; +mod mods; mod properties; mod rcon; mod 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 @@ -8,6 +8,19 @@ pub struct Account { pub password_change_required: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] +pub struct Mod { + pub id: i64, + pub instance_id: i64, + pub filename: String, + pub display_name: Option, + pub side: String, + pub accepted: bool, + pub rejection_reason: Option, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Instance { pub id: i64, @@ -23,6 +36,7 @@ pub struct Instance { pub gamemode: i32, pub max_players: i32, pub pvp: bool, + pub whitelist_enabled: bool, pub motd: String, pub rcon_password: String, 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 @@ +//! Mod validation: two-layer inspection of Forge/Fabric mod JARs. +//! +//! 1. Metadata check — reads `META-INF/mods.toml` (Forge) or `fabric.mod.json` (Fabric). +//! 2. Class scan — walks `.class` entries looking for `net/minecraft/server` +//! vs `net/minecraft/client` references. +//! +//! **Rejection policy:** only mods that explicitly declare themselves client-only +//! in their metadata (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client) +//! are rejected. The class scan is purely informational — many Forge mods that +//! work on servers reference `net/minecraft/client/…` for rendering/GUI but do +//! their server work through common API classes (`net/minecraft/block/Block`, etc.) +//! without ever referencing `net/minecraft/server/…` directly. + +use std::io::{Cursor, Read}; +use zip::ZipArchive; + +/// The canonical side for a mod, derived from metadata or class scan. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ModSide { + Both, + Client, + Server, + Unknown, +} + +impl std::fmt::Display for ModSide { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ModSide::Both => write!(f, "BOTH"), + ModSide::Client => write!(f, "CLIENT"), + ModSide::Server => write!(f, "SERVER"), + ModSide::Unknown => write!(f, "UNKNOWN"), + } + } +} + +/// The result of validating a single mod file. +#[derive(Debug)] +pub struct ValidationResult { + pub accepted: bool, + pub rejection_reason: Option, + pub display_name: Option, + pub side: ModSide, +} + +/// Validate a mod JAR (raw bytes, filename for error messages). +/// +/// Returns a `ValidationResult` indicating whether the mod is safe to install. +/// +/// **Rejection policy:** +/// - Only mods that explicitly declare themselves client-only **in their metadata** +/// (`mods.toml` side=CLIENT, `fabric.mod.json` environment=client) are rejected +/// outright. The class-file scan is purely informational — many Forge mods that +/// work perfectly on a server reference `net/minecraft/client/…` classes for +/// rendering/GUI while doing their server logic through common API classes +/// (`net/minecraft/block/Block`, `net/minecraft/item/Item`, …) without ever +/// referencing `net/minecraft/server/…` directly. +pub fn validate_mod(data: &[u8], filename: &str) -> ValidationResult { + // Phase 1: metadata check + let (meta_side, meta_display_name) = read_metadata(data); + + // Only reject based on metadata — the mod author's explicit declaration. + if meta_side == Some(ModSide::Client) { + return ValidationResult { + accepted: false, + rejection_reason: Some(format!( + "Mod '{}' declares itself as client-only in its metadata. \ + It would crash the server.", + meta_display_name.as_deref().unwrap_or(filename) + )), + display_name: meta_display_name, + side: ModSide::Client, + }; + } + + // Phase 2: class scan (informational only — never causes rejection) + let class_side = scan_jar_for_side(data); + + let side = if meta_side.is_some() { + meta_side.unwrap() + } else { + match class_side { + ScanResult::HasServer => ModSide::Both, + ScanResult::OnlyClient => ModSide::Client, + ScanResult::NoClearSide => ModSide::Unknown, + } + }; + + ValidationResult { + accepted: true, + rejection_reason: if side == ModSide::Client && meta_side.is_none() { + Some("Class scan suggests this mod may be client-side only, \ + but it has been accepted anyway (no metadata declaration).".to_string()) + } else { + None + }, + display_name: meta_display_name, + side, + } +} + +// ── Metadata parsing ────────────────────────────────────────────── + +/// Read side and display-name from Forge's mods.toml or Fabric's fabric.mod.json. +/// Returns `(side, display_name)` where side is `None` if no metadata found. +fn read_metadata(data: &[u8]) -> (Option, Option) { + let cursor = Cursor::new(data); + let mut archive = match ZipArchive::new(cursor) { + Ok(a) => a, + Err(_) => return (None, None), + }; + + // Try Forge mods.toml + if let Ok(mut file) = archive.by_name("META-INF/mods.toml") { + let mut content = String::new(); + if file.read_to_string(&mut content).is_ok() + && let Some((side, name)) = parse_forge_metadata(&content) { + return (Some(side), name); + } + } + + // Try Fabric fabric.mod.json + if let Ok(mut file) = archive.by_name("fabric.mod.json") { + let mut content = String::new(); + if file.read_to_string(&mut content).is_ok() + && let Some((side, name)) = parse_fabric_metadata(&content) { + return (Some(side), name); + } + } + + (None, None) +} + +/// Parse `[[mods]]` entries from Forge's mods.toml. +fn parse_forge_metadata(content: &str) -> Option<(ModSide, Option)> { + let value: toml::Value = content.parse().ok()?; + let mods = value.get("mods")?.as_array()?; + + let mut has_both = false; + let mut has_server = false; + let mut has_client = false; + let mut display_name = None; + + for entry in mods { + let entry = entry.as_table()?; + // Use displayName or modId + let name = entry + .get("displayName") + .and_then(|v| v.as_str()) + .or_else(|| entry.get("modId").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + if display_name.is_none() { + display_name = name; + } + + match entry.get("side").and_then(|v| v.as_str()) { + Some("BOTH") => has_both = true, + Some("SERVER") => has_server = true, + Some("CLIENT") => has_client = true, + _ => {} + } + } + + if has_both || (has_server && has_client) { + Some((ModSide::Both, display_name)) + } else if has_server { + Some((ModSide::Server, display_name)) + } else if has_client { + Some((ModSide::Client, display_name)) + } else { + None + } +} + +/// Parse Fabric's fabric.mod.json. +fn parse_fabric_metadata(content: &str) -> Option<(ModSide, Option)> { + let value: serde_json::Value = content.parse().ok()?; + let env = value.get("environment").and_then(|v| v.as_str()); + let name = value + .get("name") + .and_then(|v| v.as_str()) + .or_else(|| value.get("id").and_then(|v| v.as_str())) + .map(|s| s.to_string()); + + match env { + Some("*") | Some("server") => Some((ModSide::Both, name)), + Some("client") => Some((ModSide::Client, name)), + _ => None, + } +} + +// ── Class-file scan ─────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ScanResult { + /// At least one class references server paths (mod is safe). + HasServer, + /// Every class that references Minecraft packages only references client paths. + OnlyClient, + /// No class gave a clear indication either way. + NoClearSide, +} + +/// Walk all `.class` entries in the JAR and look for client vs server references. +/// +/// Two independent signal layers: +/// 1. **Server-content**: `net/minecraft/block/`, `net/minecraft/tileentity/`, +/// `net/minecraft/command/`, `net/minecraft/world/gen/`, `net/minecraft/entity/ai/`, +/// `net/minecraft/pathfinding/`, `net/minecraft/server/`. +/// 2. **Client-API**: `net/minecraft/client/`, `net/minecraftforge/client/`, +/// `net/minecraftforge/fml/client/`. +/// +/// The `@Mod` annotation (`net/minecraftforge/fml/common/Mod`) is deliberately +/// excluded from both sets — it is mandatory boilerplate for EVERY Forge mod +/// and says nothing about server compatibility. +fn scan_jar_for_side(data: &[u8]) -> ScanResult { + let cursor = Cursor::new(data); + let mut archive = match ZipArchive::new(cursor) { + Ok(a) => a, + Err(_) => return ScanResult::NoClearSide, + }; + + let mut has_server_content = false; + let mut has_client_api = false; + + for i in 0..archive.len() { + let mut file = match archive.by_index(i) { + Ok(f) => f, + Err(_) => continue, + }; + + let name = file.name().to_string(); + if !name.ends_with(".class") { + continue; + } + + let mut bytes = Vec::with_capacity(file.size() as usize); + if file.read_to_end(&mut bytes).is_err() { + continue; + } + + let result = scan_single_class(&bytes); + match result { + ClassScan::HasServerContent => has_server_content = true, + ClassScan::HasClientApi => has_client_api = true, + ClassScan::Both => { + has_server_content = true; + has_client_api = true; + } + ClassScan::Neither => {} + } + + if has_server_content && has_client_api { + return ScanResult::HasServer; + } + } + + if has_server_content { + ScanResult::HasServer + } else if has_client_api { + ScanResult::OnlyClient + } else { + ScanResult::NoClearSide + } +} + +#[derive(Debug, PartialEq)] +enum ClassScan { + /// Found server-side content API refs (`net/minecraft/block/`, `net/minecraft/item/Item`, + /// `net/minecraft/tileentity/`, etc.) and/or direct `net/minecraft/server/` refs. + HasServerContent, + /// Found only client-side API refs (`net/minecraft/client/gui/`, + /// `net/minecraftforge/client/`, `net/minecraftforge/fml/client/`) — + /// no server or content API refs. + HasClientApi, + /// Found both server-content AND client-api refs in the same class. + Both, + /// No Minecraft references at all. + Neither, +} + +/// Server-side content API path prefixes — mods referencing these +/// provide actual world content (blocks, tile entities, commands, +/// worldgen, AI/pathfinding). +/// +/// `net/minecraft/item/Item` is deliberately excluded — the Item class is +/// referenced by both client-only mods (item tooltips, inventory rendering) +/// and server mods (item registration), so it's not a reliable indicator. +/// The `ItemStack` substring makes it even worse. +/// +/// Forge-specific packages (`net/minecraftforge/common/`, etc.) are also +/// excluded — they are shared client+server APIs referenced by every +/// Forge mod including purely client-side ones. +const SERVER_CONTENT_PREFIXES: &[&[u8]] = &[ + b"net/minecraft/block/", + b"net/minecraft/tileentity/", + b"net/minecraft/command/", + b"net/minecraft/world/gen/", + b"net/minecraft/entity/ai/", // entity AI (server-side only) + b"net/minecraft/pathfinding/", // pathfinding (server-side only) +]; + +/// Client-side API path prefixes — mods referencing ONLY these (without +/// any SERVER_CONTENT_PREFIXES) are likely client-only. +/// +/// `net/minecraft/client/` covers everything under the client package +/// (Minecraft, gui, renderer, resources, settings, network handlers, etc.) +/// — all are client-only classes that don't exist on the server. +const CLIENT_API_PREFIXES: &[&[u8]] = &[ + b"net/minecraft/client/", + b"net/minecraftforge/client/", + b"net/minecraftforge/fml/client/", +]; + +/// Check a single `.class` file's raw bytes for server-side content API vs +/// client-side API references in the constant pool. +/// +/// We parse the constant pool minimally to find `CONSTANT_Utf8` entries, +/// which store the actual string literals (class names, method signatures, etc.) +/// used by the bytecode. This avoids false positives from raw byte scanning. +fn scan_single_class(data: &[u8]) -> ClassScan { + if data.len() < 10 { + return ClassScan::Neither; + } + if data[0..4] != [0xCA, 0xFE, 0xBA, 0xBE] { + return ClassScan::Neither; + } + + let cp_count = read_u16_be(&data[8..10]) as usize; + if cp_count < 1 { + return ClassScan::Neither; + } + + let mut pos: usize = 10; + let mut has_server = false; + let mut has_client = false; + let mut i = 1; + + while i < cp_count && pos < data.len() { + if pos >= data.len() { + break; + } + let tag = data[pos]; + pos += 1; + + match tag { + 1 => { + if pos + 2 > data.len() { + break; + } + let len = read_u16_be(&data[pos..pos + 2]) as usize; + pos += 2; + if pos + len > data.len() { + break; + } + let slice = &data[pos..pos + len]; + + if !has_server + && ( bytes_contains(slice, b"net/minecraft/server/") + || SERVER_CONTENT_PREFIXES.iter().any(|p| bytes_contains(slice, p))) + { + has_server = true; + } + if !has_client + && CLIENT_API_PREFIXES.iter().any(|p| bytes_contains(slice, p)) + { + has_client = true; + } + pos += len; + + if has_server && has_client { + return ClassScan::Both; + } + } + // CONSTANT_Integer, CONSTANT_Float, CONSTANT_Fieldref, + // CONSTANT_Methodref, CONSTANT_InterfaceMethodref, + // CONSTANT_Dynamic, CONSTANT_InvokeDynamic + 3 | 4 | 9 | 10 | 11 | 12 | 17 | 18 => { + if pos + 4 > data.len() { + break; + } + pos += 4; + } + // CONSTANT_Long, CONSTANT_Double (take 2 cp slots) + 5 | 6 => { + if pos + 8 > data.len() { + break; + } + pos += 8; + i += 1; // extra slot consumed + } + // CONSTANT_Class, CONSTANT_String, CONSTANT_MethodType, + // CONSTANT_Module, CONSTANT_Package + 7 | 8 | 16 | 19 | 20 => { + if pos + 2 > data.len() { + break; + } + pos += 2; + } + // CONSTANT_MethodHandle + 15 => { + if pos + 3 > data.len() { + break; + } + pos += 3; + } + // Unknown/invalid tag — bail to avoid misparse + _ => break, + } + i += 1; + } + + if has_server && has_client { + ClassScan::Both + } else if has_server { + ClassScan::HasServerContent + } else if has_client { + ClassScan::HasClientApi + } else { + ClassScan::Neither + } +} + +// ── Helpers ─────────────────────────────────────────────────────── + +#[inline] +fn read_u16_be(bytes: &[u8]) -> u16 { + u16::from_be_bytes([bytes[0], bytes[1]]) +} + +/// Check if `haystack` contains `needle` at any position. +#[inline] +fn bytes_contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_read_u16_be() { + assert_eq!(read_u16_be(&[0x00, 0x01]), 1); + assert_eq!(read_u16_be(&[0x01, 0x00]), 256); + assert_eq!(read_u16_be(&[0xFF, 0xFF]), 65535); + } + + #[test] + fn test_bytes_contains() { + assert!(bytes_contains(b"hello world", b"world")); + assert!(!bytes_contains(b"hello world", b"foo")); + assert!(bytes_contains(b"net/minecraft/server/MinecraftServer", b"net/minecraft/server/")); + assert!(bytes_contains(b"net/minecraft/client/Minecraft", b"net/minecraft/client/")); + } + + #[test] + fn test_invalid_class_file() { + assert_eq!(scan_single_class(&[0x00; 4]), ClassScan::Neither); + assert_eq!(scan_single_class(&[]), ClassScan::Neither); + } + + #[test] + fn test_minimal_class_file() { + // Valid magic + zero constant pool count — should not crash + let data = [0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x3D, 0x00, 0x00]; + assert_eq!(scan_single_class(&data), ClassScan::Neither); + } + + #[test] + fn test_empty_jar() { + // Build an empty valid zip + let buf = { + let mut cursor = Cursor::new(Vec::new()); + let zip = zip::ZipWriter::new(&mut cursor); + zip.finish().unwrap(); + cursor.into_inner() + }; + assert_eq!(scan_jar_for_side(&buf), ScanResult::NoClearSide); + } +} 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( if let Ok(entries) = std::fs::read_dir(&data_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.is_file() { - if let Some(name) = path.file_name() { - if let Ok(contents) = std::fs::read(&path) { + if path.is_file() + && let Some(name) = path.file_name() + && let Ok(contents) = std::fs::read(&path) { let _ = zip.start_file(&*name.to_string_lossy(), options); let _ = std::io::Write::write_all(&mut zip, &contents); } - } - } } } 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 { instance: Instance, current_seed: Option, max_memory_mb: u32, + mod_count: i64, + rejected_count: i64, } struct InstanceInfo { @@ -74,6 +76,7 @@ pub struct EditInstanceForm { pub gamemode: i32, pub max_players: i32, pub pvp: Option, + pub whitelist_enabled: Option, pub motd: Option, pub memory_mb: i32, } @@ -216,8 +219,8 @@ async fn edit_form( let conn = state.db.conn(); conn.query_row( "SELECT id, name, version, server_type, world_source, world_seed, level_type, - has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, - rcon_password, is_active, created_at, updated_at + has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, whitelist_enabled, + motd, rcon_password, is_active, created_at, updated_at FROM instances WHERE id = ?1", params![id], |row| { @@ -235,11 +238,12 @@ async fn edit_form( gamemode: row.get(10)?, max_players: row.get(11)?, pvp: row.get::<_, i32>(12)? != 0, - motd: row.get(13)?, - rcon_password: row.get(14)?, - is_active: row.get::<_, i32>(15)? != 0, - created_at: row.get(16)?, - updated_at: row.get(17)?, + whitelist_enabled: row.get::<_, i32>(13)? != 0, + motd: row.get(14)?, + rcon_password: row.get(15)?, + is_active: row.get::<_, i32>(16)? != 0, + created_at: row.get(17)?, + updated_at: row.get(18)?, }) }, ) @@ -272,6 +276,26 @@ async fn edit_form( current_seed }; + let (mod_count, rejected_count) = { + let conn = state.db.conn(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mods WHERE instance_id = ?1", + params![id], + |row| row.get(0), + ) + .unwrap_or(0); + let rejected: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 0", + params![id], + |row| row.get(0), + ) + .unwrap_or(0); + drop(conn); + (total, rejected) + }; + let template = EditInstanceTemplate { username: account.username.clone(), is_superuser: account.is_superuser, @@ -279,6 +303,8 @@ async fn edit_form( instance, current_seed, max_memory_mb: state.config.max_memory_mb, + mod_count, + rejected_count, }; Ok(Html(template.render().unwrap_or_default())) @@ -295,18 +321,20 @@ async fn edit_instance( } let pvp = form.pvp.as_deref() == Some("on"); + let whitelist_enabled = form.whitelist_enabled.as_deref() == Some("on"); let is_active = { let conn = state.db.conn(); conn.execute( "UPDATE instances SET difficulty = ?1, gamemode = ?2, max_players = ?3, - pvp = ?4, motd = ?5, memory_mb = ?6, updated_at = datetime('now') - WHERE id = ?7", + pvp = ?4, whitelist_enabled = ?5, motd = ?6, memory_mb = ?7, + updated_at = datetime('now') WHERE id = ?8", params![ form.difficulty, form.gamemode, form.max_players, pvp, + whitelist_enabled, form.motd, form.memory_mb, id, @@ -390,8 +418,8 @@ async fn activate_instance( conn.query_row( "SELECT id, name, version, server_type, world_source, world_seed, level_type, - has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, motd, - rcon_password, is_active, created_at, updated_at + has_modpack, memory_mb, difficulty, gamemode, max_players, pvp, whitelist_enabled, + motd, rcon_password, is_active, created_at, updated_at FROM instances WHERE id = ?1", params![id], |row| { @@ -409,11 +437,12 @@ async fn activate_instance( gamemode: row.get(10)?, max_players: row.get(11)?, pvp: row.get::<_, i32>(12)? != 0, - motd: row.get(13)?, - rcon_password: row.get(14)?, - is_active: row.get::<_, i32>(15)? != 0, - created_at: row.get(16)?, - updated_at: row.get(17)?, + whitelist_enabled: row.get::<_, i32>(13)? != 0, + motd: row.get(14)?, + rcon_password: row.get(15)?, + is_active: row.get::<_, i32>(16)? != 0, + created_at: row.get(17)?, + updated_at: row.get(18)?, }) }, ) 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; pub mod backups; pub mod dashboard; pub mod instances; +pub mod mods; pub mod players; use askama::Template; @@ -35,6 +36,7 @@ pub fn build_router(state: AppState) -> Router { .merge(players::router()) .merge(accounts::router()) .merge(backups::router()) + .merge(mods::router()) .layer(auth_required); 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 @@ +use askama::Template; +use axum::{ + Router, + extract::{Path, State}, + http::Request, + body::Body, + response::{Html, Redirect}, + routing::{get, post}, +}; +use rusqlite::params; +use std::io::{Cursor, Read}; + +use crate::auth::CurrentUser; +use crate::mods; +use crate::routes::AppState; + +#[derive(Template)] +#[template(path = "mods/list.html")] +struct ModListTemplate { + username: String, + is_superuser: bool, + password_change_required: bool, + instance_name: String, + instance_id: i64, + mods: Vec, + flash: Option, +} + +struct ModRow { + id: i64, + filename: String, + display_name: Option, + side: String, + accepted: bool, + rejection_reason: Option, + created_at: String, +} + +struct FlashMessage { + kind: String, + text: String, +} + +pub fn router() -> Router { + Router::new() + .route("/instances/{id}/mods", get(list_mods).post(upload_mod)) + .route("/instances/{id}/mods/{mod_id}/delete", post(delete_mod)) + .route("/instances/{id}/mods/rescan", post(rescan_mods)) +} + +async fn list_mods( + State(state): State, + Path(instance_id): Path, + CurrentUser(account): CurrentUser, +) -> Result, axum::response::Redirect> { + let conn = state.db.conn(); + let instance_name: String = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![instance_id], + |row| row.get(0), + ) + .map_err(|_| Redirect::to("/instances"))?; + + let mut stmt = conn + .prepare( + "SELECT id, filename, display_name, side, accepted, rejection_reason, created_at + FROM mods WHERE instance_id = ?1 ORDER BY created_at DESC", + ) + .map_err(|_| Redirect::to("/instances"))?; + + let mods: Vec = stmt + .query_map(params![instance_id], |row| { + Ok(ModRow { + id: row.get(0)?, + filename: row.get(1)?, + display_name: row.get(2)?, + side: row.get(3)?, + accepted: row.get::<_, i32>(4)? != 0, + rejection_reason: row.get(5)?, + created_at: row.get(6)?, + }) + }) + .map_err(|_| Redirect::to("/instances"))? + .filter_map(|r| r.ok()) + .collect(); + drop(stmt); + drop(conn); + + let template = ModListTemplate { + username: account.username.clone(), + is_superuser: account.is_superuser, + password_change_required: account.password_change_required, + instance_name, + instance_id, + mods, + flash: None, + }; + + match template.render() { + Ok(html) => Ok(Html(html)), + Err(e) => { + tracing::error!("Failed to render mods template: {e}"); + Err(Redirect::to("/instances")) + } + } +} + +async fn upload_mod( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, + req: Request, +) -> Result { + tracing::info!("Upload mod request for instance {instance_id}"); + + let instance_name = match get_instance_name(&state, instance_id) { + Some(n) => n, + None => return Err(Redirect::to("/instances")), + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("mods"); + let _ = std::fs::create_dir_all(&mods_dir); + + // Build multer multipart parser with generous size limit (500MB) + let (parts, body) = req.into_parts(); + let content_type = parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + tracing::info!("Content-Type: {content_type}"); + + let boundary = content_type + .split(';') + .find_map(|part| { + let part = part.trim(); + part.strip_prefix("boundary=").map(|b| b.trim_matches('"').to_string()) + }) + .unwrap_or_default(); + + if boundary.is_empty() { + tracing::error!("No boundary found in Content-Type"); + return Err(Redirect::to(&format!("/instances/{instance_id}/mods"))); + } + + let constraints = multer::Constraints::new() + .size_limit(multer::SizeLimit::new() + .whole_stream(500 * 1024 * 1024) + .per_field(500 * 1024 * 1024)); + let stream = body.into_data_stream(); + let mut multipart = multer::Multipart::with_constraints(stream, boundary, constraints); + + let mut processed = 0u32; + loop { + match multipart.next_field().await { + Ok(Some(field)) => { + let file_name = field + .file_name() + .map(|s| s.to_string()) + .unwrap_or_else(|| "mod.jar".to_string()); + tracing::info!( + "Field: {:?}, file: {}, content-type: {:?}", + field.name(), + file_name, + field.content_type().map(|s| s.to_string()) + ); + + // Read field data as bytes + let buf = match field.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => { + tracing::error!("Failed to read field bytes: {e}"); + continue; + } + }; + + if buf.is_empty() { + tracing::warn!("Empty field, skipping"); + continue; + } + tracing::info!("Read {} bytes from {}", buf.len(), file_name); + + if file_name.ends_with(".zip") || file_name.ends_with(".ZIP") { + process_zip_upload(&state, instance_id, &buf, &file_name, &mods_dir); + } else if file_name.ends_with(".jar") || file_name.ends_with(".JAR") { + process_jar_upload(&state, instance_id, &buf, &file_name, &mods_dir); + } else { + tracing::warn!("Unsupported file type: {file_name}"); + } + processed += 1; + } + Ok(None) => break, + Err(e) => { + tracing::error!("Multer error: {e}"); + break; + } + } + } + + tracing::info!("Upload done, processed {processed} files"); + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +fn get_instance_name(state: &AppState, id: i64) -> Option { + let conn = state.db.conn(); + let result = conn + .query_row( + "SELECT name FROM instances WHERE id = ?1", + params![id], + |row| row.get::<_, String>(0), + ) + .ok(); + drop(conn); + result +} + +fn process_jar_upload( + state: &AppState, + instance_id: i64, + bytes: &[u8], + filename: &str, + mods_dir: &std::path::Path, +) { + // Strip directory components — use only the basename + let safe_name = std::path::Path::new(filename) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| filename.to_string()); + + tracing::info!("Validating {safe_name}"); + let result = mods::validate_mod(bytes, &safe_name); + tracing::info!( + "Result for {safe_name}: accepted={}, side={}", + result.accepted, + result.side + ); + + if result.accepted { + let dest = mods_dir.join(&safe_name); + if std::fs::write(&dest, bytes).is_ok() { + tracing::info!("Written to disk: {}", dest.display()); + } else { + tracing::error!("Failed to write {}", dest.display()); + } + } + + let conn = state.db.conn(); + if let Err(e) = conn.execute( + "INSERT INTO mods (instance_id, filename, display_name, side, accepted, rejection_reason) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + instance_id, + safe_name, + result.display_name, + result.side.to_string(), + result.accepted, + result.rejection_reason, + ], + ) { + tracing::error!("DB insert failed: {e}"); + } + drop(conn); + + if result.accepted { + let conn = state.db.conn(); + let _ = conn.execute( + "UPDATE instances SET has_modpack = 1 WHERE id = ?1", + params![instance_id], + ); + drop(conn); + } +} + +fn process_zip_upload( + state: &AppState, + instance_id: i64, + bytes: &[u8], + filename: &str, + mods_dir: &std::path::Path, +) { + tracing::info!("Extracting ZIP archive {filename}"); + let cursor = Cursor::new(bytes); + let mut archive = match zip::ZipArchive::new(cursor) { + Ok(a) => a, + Err(e) => { + tracing::error!("Failed to read ZIP {filename}: {e}"); + return; + } + }; + + for i in 0..archive.len() { + let mut entry = match archive.by_index(i) { + Ok(e) => e, + Err(_) => continue, + }; + + let entry_name = entry.name().to_string(); + if !entry_name.ends_with(".jar") && !entry_name.ends_with(".JAR") { + continue; + } + + // Use only the basename (strip directory components) + let base_name = std::path::Path::new(&entry_name) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| entry_name.clone()); + + let mut jar_bytes = Vec::new(); + if entry.read_to_end(&mut jar_bytes).is_err() { + continue; + } + + tracing::info!( + "Found JAR inside ZIP: {entry_name} => {base_name} ({} bytes)", + jar_bytes.len() + ); + process_jar_upload(state, instance_id, &jar_bytes, &base_name, mods_dir); + } +} + +async fn rescan_mods( + State(state): State, + Path(instance_id): Path, + CurrentUser(_user): CurrentUser, +) -> Result { + let instance_name = match get_instance_name(&state, instance_id) { + Some(n) => n, + None => return Err(Redirect::to("/instances")), + }; + + let mods_dir = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("mods"); + + if let Ok(entries) = std::fs::read_dir(&mods_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + if !filename.ends_with(".jar") && !filename.ends_with(".JAR") { + continue; + } + let data = match std::fs::read(&path) { + Ok(d) => d, + Err(_) => continue, + }; + + tracing::info!("Rescanning existing mod: {filename}"); + process_jar_upload(&state, instance_id, &data, &filename, &mods_dir); + } + } + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} + +async fn delete_mod( + State(state): State, + Path((instance_id, mod_id)): Path<(i64, i64)>, + CurrentUser(_user): CurrentUser, +) -> Result { + let conn = state.db.conn(); + let (filename, instance_name): (String, String) = conn + .query_row( + "SELECT m.filename, i.name FROM mods m + JOIN instances i ON i.id = m.instance_id + WHERE m.id = ?1 AND m.instance_id = ?2", + params![mod_id, instance_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| Redirect::to(&format!("/instances/{instance_id}/mods")))?; + + // Delete file from disk + let mod_path = state + .config + .server_directory + .join("instances") + .join(&instance_name) + .join("data") + .join("mods") + .join(&filename); + let _ = std::fs::remove_file(&mod_path); + + // Remove from DB + conn.execute("DELETE FROM mods WHERE id = ?1", params![mod_id]) + .ok(); + + // Update has_modpack flag: clear if no mods remain + let remaining: i64 = conn + .query_row( + "SELECT COUNT(*) FROM mods WHERE instance_id = ?1 AND accepted = 1", + params![instance_id], + |row| row.get(0), + ) + .unwrap_or(0); + if remaining == 0 { + conn.execute( + "UPDATE instances SET has_modpack = 0 WHERE id = ?1", + params![instance_id], + ) + .ok(); + } + drop(conn); + + Ok(Redirect::to(&format!("/instances/{instance_id}/mods"))) +} 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( } fn read_json_list(path: &std::path::Path) -> Vec { - if let Ok(contents) = std::fs::read_to_string(path) { - if let Ok(entries) = serde_json::from_str::>(&contents) { + if let Ok(contents) = std::fs::read_to_string(path) + && let Ok(entries) = serde_json::from_str::>(&contents) { return entries .iter() .filter_map(|e| e["name"].as_str().map(String::from)) .collect(); } - } vec![] } 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 @@ *, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; + box-sizing: border-box; + margin: 0; + padding: 0; } :root { - --bg: #1a1b1e; - --bg-card: #25262b; - --bg-input: #2c2e33; - --fg: #c1c2c5; - --fg-muted: #909296; - --accent: #4c9aff; - --ok: #51cf66; - --warn: #fcc419; - --err: #ff6b6b; - --border: #373a40; - --radius: 8px; + --bg: #1a1b1e; + --bg-card: #25262b; + --bg-input: #2c2e33; + --fg: #c1c2c5; + --fg-muted: #909296; + --accent: #4c9aff; + --ok: #51cf66; + --warn: #fcc419; + --err: #ff6b6b; + --border: #373a40; + --radius: 8px; } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--fg); - line-height: 1.5; - min-height: 100vh; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.5; + min-height: 100vh; } /* Navigation */ nav { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 1.5rem; - background: var(--bg-card); - border-bottom: 1px solid var(--border); - flex-wrap: wrap; - gap: 0.5rem; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; + gap: 0.5rem; } nav .brand { - font-weight: 700; - font-size: 1.1rem; - color: var(--accent); + font-weight: 700; + font-size: 1.1rem; + color: var(--accent); } nav .nav-links { - display: flex; - align-items: center; - gap: 1rem; - flex-wrap: wrap; + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; } nav a { - color: var(--fg); - text-decoration: none; - font-size: 0.9rem; + color: var(--fg); + text-decoration: none; + font-size: 0.9rem; } nav a:hover { - color: var(--accent); + color: var(--accent); } nav .nav-user { - color: var(--fg-muted); - font-size: 0.85rem; + color: var(--fg-muted); + font-size: 0.85rem; } /* Buttons */ -.btn, button { - display: inline-block; - padding: 0.5rem 1rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-input); - color: var(--fg); - cursor: pointer; - font-size: 0.9rem; - text-decoration: none; - transition: background 0.15s; +.btn, +button { + display: inline-block; + padding: 0.5rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-input); + color: var(--fg); + cursor: pointer; + font-size: 0.9rem; + text-decoration: none; + transition: background 0.15s; } -.btn:hover, button:hover { - background: var(--border); +.btn:hover, +button:hover { + background: var(--border); } -.btn-ok, button.btn-ok { - background: var(--ok); - color: #000; - border-color: var(--ok); +.btn-ok, +button.btn-ok { + background: var(--ok); + color: #000; + border-color: var(--ok); } .btn-ok:hover { - opacity: 0.85; + opacity: 0.85; } -.btn-danger, button.btn-danger { - background: var(--err); - color: #000; - border-color: var(--err); +.btn-danger, +button.btn-danger { + background: var(--err); + color: #000; + border-color: var(--err); } .btn-danger:hover { - opacity: 0.85; + opacity: 0.85; } .btn-sm { - padding: 0.25rem 0.6rem; - font-size: 0.8rem; + padding: 0.25rem 0.6rem; + font-size: 0.8rem; } .btn-link { - background: none; - border: none; - color: var(--fg-muted); - cursor: pointer; - padding: 0; + background: none; + border: none; + color: var(--fg-muted); + cursor: pointer; + padding: 0; } .btn-link:hover { - color: var(--err); + color: var(--err); } /* Cards */ .card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1.25rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.25rem; } .card-ok { - border-color: var(--ok); + border-color: var(--ok); } .card-err { - border-color: var(--err); + border-color: var(--err); } .card-muted { - opacity: 0.7; + opacity: 0.7; } .card-active { - border-color: var(--accent); - position: relative; + border-color: var(--accent); + position: relative; } .active-badge { - position: absolute; - top: -8px; - right: -8px; - background: var(--accent); - color: #000; - font-size: 0.7rem; - font-weight: 700; - padding: 0.15rem 0.5rem; - border-radius: 100px; + position: absolute; + top: -8px; + right: -8px; + background: var(--accent); + color: #000; + font-size: 0.7rem; + font-weight: 700; + padding: 0.15rem 0.5rem; + border-radius: 100px; } /* Forms */ form.form-col { - display: flex; - flex-direction: column; - gap: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; } form.form-col:not(.form-wide) { - max-width: 600px; + max-width: 600px; } form.form-row { - display: flex; - align-items: center; - gap: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; } label { - display: flex; - flex-direction: column; - gap: 0.25rem; - font-size: 0.9rem; - color: var(--fg-muted); + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.9rem; + color: var(--fg-muted); } label:has(input[type="radio"]), label:has(input[type="checkbox"]) { - flex-direction: row; - align-items: center; - gap: 0.5rem; - color: var(--fg); + flex-direction: row; + align-items: center; + gap: 0.5rem; + color: var(--fg); } -input, select, textarea { - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg-input); - color: var(--fg); - font-size: 0.9rem; +input, +select, +textarea { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-input); + color: var(--fg); + font-size: 0.9rem; } -input:focus, select:focus { - outline: none; - border-color: var(--accent); +input:focus, +select:focus { + outline: none; + border-color: var(--accent); } fieldset { - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; } legend { - font-weight: 600; - padding: 0 0.5rem; - color: var(--fg-muted); - font-size: 0.85rem; + font-weight: 600; + padding: 0 0.5rem; + color: var(--fg-muted); + font-size: 0.85rem; } small { - color: var(--fg-muted); - font-size: 0.8rem; + color: var(--fg-muted); + font-size: 0.8rem; } /* Layout */ main { - max-width: 1000px; - margin: 0 auto; - padding: 1.5rem; + max-width: 1000px; + margin: 0 auto; + padding: 1.5rem; } -h1, h2, h3 { - color: var(--fg); - margin-bottom: 0.75rem; +h1, +h2, +h3 { + color: var(--fg); + margin-bottom: 0.75rem; } .page-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1.5rem; - flex-wrap: wrap; - gap: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + flex-wrap: wrap; + gap: 1rem; } .instance-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; } .tab-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; } /* Status badges */ .status-badge { - display: inline-block; - padding: 0.25rem 0.75rem; - border-radius: 100px; - font-size: 0.85rem; - font-weight: 600; + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 100px; + font-size: 0.85rem; + font-weight: 600; } .status-running { - background: var(--ok); - color: #000; + background: var(--ok); + color: #000; } -.status-starting, .status-booting, .status-pulling { - background: var(--warn); - color: #000; +.status-starting, +.status-booting, +.status-pulling { + background: var(--warn); + color: #000; } .status-stopped { - background: var(--fg-muted); - color: #000; + background: var(--fg-muted); + color: #000; } -.status-error, .status-not_found { - background: var(--err); - color: #000; +.status-warning { + background: var(--warn); + color: #000; +} + +.status-error, +.status-not_found { + background: var(--err); + color: #000; } .status-row { - display: flex; - align-items: center; - gap: 1rem; - margin: 0.75rem 0; + display: flex; + align-items: center; + gap: 1rem; + margin: 0.75rem 0; } /* Tags */ .tag { - display: inline-block; - padding: 0.1rem 0.5rem; - border-radius: 100px; - font-size: 0.75rem; - background: var(--border); - color: var(--fg-muted); + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 100px; + font-size: 0.75rem; + background: var(--border); + color: var(--fg-muted); } .tag-admin { - background: var(--warn); - color: #000; + background: var(--warn); + color: #000; +} + +.tag-ok { + background: var(--ok); + color: #000; +} + +.tag-err { + background: var(--err); + color: #000; } /* Instance meta */ -.instance-meta, .meta { - display: flex; - gap: 0.75rem; - flex-wrap: wrap; - font-size: 0.85rem; - color: var(--fg-muted); - margin-bottom: 0.5rem; +.instance-meta, +.meta { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.85rem; + color: var(--fg-muted); + margin-bottom: 0.5rem; } .actions { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-top: 0.75rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + margin-top: 0.75rem; } .players { - font-size: 0.9rem; - color: var(--accent); + font-size: 0.9rem; + color: var(--accent); } /* Player list */ .player-list { - list-style: none; - margin-top: 0.5rem; + list-style: none; + margin-top: 0.5rem; } .player-list li { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.4rem 0; - border-bottom: 1px solid var(--border); - font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; } /* Table */ .table { - width: 100%; - border-collapse: collapse; - font-size: 0.9rem; + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; } -.table th, .table td { - text-align: left; - padding: 0.5rem; - border-bottom: 1px solid var(--border); +.table th, +.table td { + text-align: left; + padding: 0.5rem; + border-bottom: 1px solid var(--border); } .table th { - color: var(--fg-muted); - font-weight: 600; + color: var(--fg-muted); + font-weight: 600; } /* Flash messages */ .flash { - padding: 0.75rem 1rem; - border-radius: var(--radius); - margin-bottom: 1rem; - font-size: 0.9rem; + padding: 0.75rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.9rem; } .flash-err { - background: color-mix(in srgb, var(--err) 15%, transparent); - border: 1px solid var(--err); - color: var(--err); + background: color-mix(in srgb, var(--err) 15%, transparent); + border: 1px solid var(--err); + color: var(--err); } .flash-ok { - background: color-mix(in srgb, var(--ok) 15%, transparent); - border: 1px solid var(--ok); - color: var(--ok); + background: color-mix(in srgb, var(--ok) 15%, transparent); + border: 1px solid var(--ok); + color: var(--ok); } .flash-warn { - background: color-mix(in srgb, var(--warn) 15%, transparent); - border: 1px solid var(--warn); - color: var(--warn); + background: color-mix(in srgb, var(--warn) 15%, transparent); + border: 1px solid var(--warn); + color: var(--warn); } /* Login page */ .login-page { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 100vh; - max-width: 380px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + max-width: 380px; } .login-form { - width: 100%; - display: flex; - flex-direction: column; - gap: 1rem; + width: 100%; + display: flex; + flex-direction: column; + gap: 1rem; } .login-form button { - margin-top: 0.5rem; + margin-top: 0.5rem; } /* Responsive */ @media (max-width: 600px) { - nav { - flex-direction: column; - align-items: flex-start; - } - - .instance-grid { - grid-template-columns: 1fr; - } + nav { + flex-direction: column; + align-items: flex-start; + } + + .instance-grid { + grid-template-columns: 1fr; + } } 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 @@ - + - - - + + + Minecraft UI - - - -