From 0d61ad11078646eb2bf9d05a1a9fc7b44a90d2c0 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Wed, 1 Jul 2026 19:36:52 +0000 Subject: [PATCH] =?UTF-8?q?DevBlog=20=E2=84=964=20|=20=D0=BC=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=84=D0=B8=D0=BA=D1=81=20JFX?= =?UTF-8?q?=20=D0=B8=20=D1=84=D0=B8=D0=BA=D1=81=D1=8B=20=D1=81=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=20=D1=81=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=D0=BC=20=D0=BB=D0=B0=D1=83=D0=BD=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=BE=D0=BC,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zernmc/launcher/auth/AuthManager.java | 6 +- .../launcher/minecraft/PackDownloader.java | 31 + .../minecraft/installer/VersionInstaller.java | 90 +++ .../zernmc/launcher/ui/jfx/JFXLauncher.java | 283 +++++++- .../zernmc/launcher/utils/ProgressBar.java | 14 +- launcher/launcher/src/resources/ui/index.html | 125 +++- .../launcher/src/resources/ui/launcher.js | 615 ++++++++++++++++-- launcher/launcher/src/resources/ui/style.css | 136 +++- server/admin_router.py | 333 +++++++++- server/main.py | 88 ++- test_install_modal.py | 281 ++++++++ test_launcher_integration.py | 12 + 12 files changed, 1915 insertions(+), 99 deletions(-) create mode 100644 test_install_modal.py diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java index 0096529..bbb16ed 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/auth/AuthManager.java @@ -85,7 +85,11 @@ public class AuthManager { public static boolean tryAutoLogin() { if (isLoggedIn()) return true; if (!Files.exists(AUTH_FILE)) return false; - return loadSavedSession(); + boolean loaded = loadSavedSession(); + if (loaded) { + refreshUserInfo(); + } + return loaded; } public static AuthResult login(String username, String password) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java index fac176f..c66ab23 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/PackDownloader.java @@ -28,15 +28,33 @@ import java.util.concurrent.atomic.AtomicInteger; public class PackDownloader { + @FunctionalInterface + public interface ProgressCallback { + void onProgress(String label, int percent, String stageName, int stageIndex, int stageCount); + } + private final Instance instance; private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); private final HttpClient httpClient = HttpClient.newHttpClient(); + private ProgressCallback progressCallback; //private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME; public PackDownloader(Instance instance) { this.instance = instance; } + public void setProgressCallback(ProgressCallback callback) { + this.progressCallback = callback; + } + + private void reportProgress(String label, int percent, String stageName, int stageIndex, int stageCount) { + if (progressCallback != null) { + try { + progressCallback.onProgress(label, percent, stageName, stageIndex, stageCount); + } catch (Exception ignored) {} + } + } + /** * Get list of available packs from server */ @@ -140,6 +158,7 @@ public class PackDownloader { */ public boolean installOrUpdatePack(String packName, ServerPack serverPack) throws Exception { LauncherLogger.info("Installing pack " + packName + " from server..."); + reportProgress("Fetching manifest...", 0, "manifest", 0, 5); // 1. Get manifest PackManifest manifest = getPackManifest(packName); @@ -157,24 +176,28 @@ public class PackDownloader { boolean success = lib.installFabric(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install Fabric")); + reportProgress("Failed to install Fabric", 0, "error", 0, 1); return false; } } else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) { boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install NeoForge")); + reportProgress("Failed to install NeoForge", 0, "error", 0, 1); return false; } } else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) { boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion()); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install Forge")); + reportProgress("Failed to install Forge", 0, "error", 0, 1); return false; } } else { boolean success = lib.installMinecraft(manifest.getMinecraftVersion()); if (!success) { System.err.println(ZAnsi.brightRed("Failed to install Vanilla Minecraft")); + reportProgress("Failed to install Vanilla Minecraft", 0, "error", 0, 1); return false; } } @@ -182,12 +205,15 @@ public class PackDownloader { System.out.println(ZAnsi.green("Minecraft already installed, skipping...")); } + reportProgress("Scanning local files...", 30, "Installing pack files", 4, 5); + // 3. Scan local files only if there are files to download Map localFiles = scanLocalFiles(); // If pack has no files (vanilla/loader only), skip diff if (manifest.files == null || manifest.files.isEmpty()) { System.out.println(ZAnsi.green("Pack contains no additional files")); + reportProgress("Installing...", 50, "Installing pack files", 4, 5); // Update instance metadata instance.setServerPack(true); @@ -198,15 +224,18 @@ public class PackDownloader { instance.setLoaderVersion(manifest.getLoaderVersion()); instance.setAssetIndex(manifest.getAssetIndex()); + reportProgress("Pack installed successfully!", 100, "Installing pack files", 4, 5); System.out.println(ZAnsi.brightGreen("Pack installed successfully!")); return true; } // 4. Send diff request System.out.println(ZAnsi.cyan("Checking pack files...")); + reportProgress("Checking pack files...", 40, "Installing pack files", 4, 5); DiffResponse diff = getDiff(packName, localFiles); // 5. Apply changes + reportProgress("Downloading pack files...", 50, "Installing pack files", 4, 5); boolean success = applyDiff(diff, packName); if (success) { @@ -445,6 +474,8 @@ public class PackDownloader { downloaded.incrementAndGet(); if (total > 0) { ProgressBar.show("Download", downloaded.get(), total, "files"); + int pct = 50 + (downloaded.get() * 45 / total); + reportProgress("Downloading " + downloaded.get() + "/" + total + " files...", pct, "Installing pack files", 4, 5); } } catch (Exception e) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java index 83f7b15..cefb9d6 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java @@ -7,12 +7,14 @@ import org.json.JSONArray; import org.json.JSONObject; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; @@ -20,6 +22,8 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; public class VersionInstaller { @@ -58,25 +62,36 @@ public class VersionInstaller { public String install(String versionId) throws Exception { System.out.println(ZAnsi.cyan("Full install of Minecraft " + versionId + "...")); + ProgressBar.setStage("Version", 0, 5); Path versionDir = minecraftDir.resolve("versions").resolve(versionId); Files.createDirectories(versionDir); String versionUrl = getVersionUrl(versionId); if (versionUrl == null) throw new Exception("Version " + versionId + " not found"); + ProgressBar.show("Fetching version info", 0, 1, "files"); String versionJson = downloadString(versionUrl); Files.writeString(versionDir.resolve(versionId + ".json"), versionJson); + ProgressBar.show("Version info", 1, 1, "files"); JSONObject versionData = new JSONObject(versionJson); // client.jar + ProgressBar.show("Downloading client.jar", 0, 1, "files"); downloadFile(versionData.getJSONObject("downloads").getJSONObject("client").getString("url"), versionDir.resolve(versionId + ".jar"), "client.jar"); + ProgressBar.show("Client.jar", 1, 1, "files"); // Libraries + ProgressBar.setStage("Libraries", 1, 5); System.out.println(ZAnsi.cyan("Downloading libraries...")); downloadLibraries(versionData.getJSONArray("libraries")); + // Natives + ProgressBar.setStage("Natives", 2, 5); + System.out.println(ZAnsi.cyan("Extracting natives...")); + extractNatives(versionData.getJSONArray("libraries")); + String assetIndex; if (versionData.has("assetIndex")) { assetIndex = versionData.getJSONObject("assetIndex").getString("id"); @@ -87,13 +102,88 @@ public class VersionInstaller { System.out.println(ZAnsi.cyan("Asset index: " + assetIndex)); // Download assets using correct index + ProgressBar.setStage("Assets", 3, 5); System.out.println(ZAnsi.cyan("Downloading assets...")); downloadAssets(versionData, assetIndex); + ProgressBar.setStage("Done", 4, 5); System.out.println(ZAnsi.brightGreen("\nMinecraft " + versionId + " fully installed!")); return assetIndex; } + private void extractNatives(JSONArray libraries) throws Exception { + Path nativesDir = minecraftDir.resolve("natives"); + Files.createDirectories(nativesDir); + + // Determine OS classifier + String osName = System.getProperty("os.name").toLowerCase(); + String classifier; + if (osName.contains("win")) { + classifier = "natives-windows"; + } else if (osName.contains("mac")) { + classifier = "natives-macos"; + } else { + classifier = "natives-linux"; + } + + int total = libraries.length(); + int extracted = 0; + int failed = 0; + + for (int i = 0; i < total; i++) { + JSONObject lib = libraries.getJSONObject(i); + if (!lib.has("downloads") || !lib.getJSONObject("downloads").has("classifiers")) continue; + + JSONObject classifiers = lib.getJSONObject("downloads").getJSONObject("classifiers"); + if (!classifiers.has(classifier)) continue; + + JSONObject nativeArtifact = classifiers.getJSONObject(classifier); + String url = nativeArtifact.getString("url"); + String path = nativeArtifact.getString("path"); + + Path libJar = minecraftDir.resolve("libraries").resolve(path); + + // Download native library if not already present + if (!Files.exists(libJar)) { + try { + Files.createDirectories(libJar.getParent()); + downloadFile(url, libJar, ""); + } catch (Exception e) { + failed++; + continue; + } + } + + // Extract .so/.dll/.dylib from the native jar + try (JarFile jar = new JarFile(libJar.toFile())) { + var entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + if (name.endsWith(".so") || name.endsWith(".dll") || name.endsWith(".dylib") || name.endsWith(".jnilib")) { + Path outFile = nativesDir.resolve(name); + if (!entry.isDirectory()) { + Files.createDirectories(outFile.getParent()); + try (InputStream is = jar.getInputStream(entry)) { + Files.copy(is, outFile, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + } catch (Exception e) { + failed++; + } + extracted++; + ProgressBar.show("Natives", extracted, total, "files"); + } + + if (extracted > 0) { + ProgressBar.finish("Natives extracted (" + extracted + " ok, " + failed + " failed)"); + } else { + System.out.println(ZAnsi.yellow("No natives to extract for " + classifier)); + } + } + private void downloadLibraries(JSONArray libraries) throws Exception { int total = libraries.length(); int count = 0; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index 58ab3ad..f71a9d8 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -19,6 +19,7 @@ import me.sashegdev.zernmc.launcher.minecraft.ServerPack; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.LauncherLogger; +import me.sashegdev.zernmc.launcher.utils.Version; import java.awt.Desktop; import java.io.BufferedReader; @@ -29,6 +30,8 @@ import java.io.InputStreamReader; import java.net.HttpURLConnection; import me.sashegdev.zernmc.launcher.utils.ZHttpClient; import java.net.URL; +import java.net.URLDecoder; +import java.util.Base64; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.net.InetSocketAddress; @@ -192,8 +195,27 @@ public class JFXLauncher extends Application { private static void extractAssets() { try { Path assetsDir = Paths.get("assets"); + String currentVersion = Version.getCurrentVersion(); + Path versionFile = assetsDir.resolve(".version"); + + // Check if assets already match current version + if (Files.exists(assetsDir) && Files.exists(versionFile)) { + try { + String existingVersion = Files.readString(versionFile).trim(); + if (existingVersion.equals(currentVersion)) { + System.out.println("[JFX] Assets up to date (v" + currentVersion + ")"); + return; + } + } catch (Exception ignored) {} + } + + // Clean old assets if (Files.exists(assetsDir)) { - return; + try { + Files.walk(assetsDir) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(p -> { try { Files.deleteIfExists(p); } catch (Exception ignored) {} }); + } catch (Exception ignored) {} } String serverVersion = getServerVersion(); @@ -201,12 +223,14 @@ public class JFXLauncher extends Application { System.out.println("[JFX] Loading assets via meta for version " + serverVersion); if (downloadAssetsFromMeta(serverVersion)) { System.out.println("[JFX] Assets loaded via meta"); + Files.writeString(versionFile, currentVersion); return; } System.out.println("[JFX] Meta unavailable, using fallback"); } System.out.println("[JFX] Extracting assets from JAR..."); + Files.createDirectories(assetsDir); Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI()); if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) { try (JarFile jar = new JarFile(jarPath.toFile())) { @@ -220,13 +244,14 @@ public class JFXLauncher extends Application { } else { Files.createDirectories(outPath.getParent()); try (InputStream is = jar.getInputStream(entry)) { - Files.copy(is, outPath); + Files.copy(is, outPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); } } } } } System.out.println("[JFX] Assets extracted from JAR"); + Files.writeString(versionFile, currentVersion); } } catch (Exception e) { System.out.println("[JFX] Error extracting assets: " + e.getMessage()); @@ -420,6 +445,7 @@ public class JFXLauncher extends Application { LaunchService.killAllProcesses(); if (server != null) server.stop(0); Platform.exit(); + System.exit(0); }); } catch (Exception e) { @@ -472,8 +498,13 @@ public class JFXLauncher extends Application { server.createContext("/api/friends/accept", this::handleFriendAccept); server.createContext("/api/friends/requests", this::handleFriendRequests); server.createContext("/api/friends/status", this::handleFriendStatus); + server.createContext("/api/check-updates", this::handleCheckUpdates); + server.createContext("/api/update", this::handleUpdate); server.createContext("/api/playtime/sync", this::handlePlaytimeSync); server.createContext("/api/playtime/stats", this::handlePlaytimeStats); + server.createContext("/api/whitelist/mods", this::handleWhitelistMods); + server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod); + server.createContext("/api/admin", this::handleAdmin); server.createContext("/assets/", this::handleStatic); server.setExecutor(Executors.newCachedThreadPool()); @@ -711,8 +742,11 @@ public class JFXLauncher extends Application { boolean success = false; if ("zernmc".equalsIgnoreCase(loader)) { - setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 1, 4); + setInstallProgressWithStage("Fetching pack info...", 10, 100, "Fetching pack info", 0, 5); PackDownloader downloader = new PackDownloader(instance); + downloader.setProgressCallback((label, pct, stageName, stageIdx, stageCount) -> { + setInstallProgressWithStage(label, pct, 100, stageName, stageIdx, stageCount); + }); var packs = downloader.getAvailablePacks(); ServerPack pack = null; for (ServerPack p : packs) { @@ -722,17 +756,23 @@ public class JFXLauncher extends Application { } } if (pack != null) { - setInstallProgressWithStage("Downloading pack files...", 30, 100, "Downloading", 2, 4); - success = downloader.installOrUpdatePack(version, pack); + try { + success = downloader.installOrUpdatePack(version, pack); + } catch (Exception e) { + log("Pack install error: " + e.getMessage()); + throw e; + } if (success) { - setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 3, 4); + setInstallProgressWithStage("Finalizing...", 95, 100, "Finalizing", 4, 5); + } else { + log("Install error: pack install returned false"); } } else { log("Install error: pack not found on server: " + version + " (available: " + (packs != null ? packs.size() : 0) + ")"); } } else { MinecraftLib lib = new MinecraftLib(instance); - setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 1, 3); + setInstallProgressWithStage("Installing Minecraft...", 20, 100, "Installing Minecraft", 0, 3); if ("vanilla".equalsIgnoreCase(loader)) { success = lib.installMinecraft(version); } else { @@ -741,7 +781,7 @@ public class JFXLauncher extends Application { } setInstallProgressWithStage("Finalizing...", 90, 100, "Finalizing", 2, 3); } - + setInstallInProgress(false); if (success) { setInstallProgressWithStage("Installation complete!", 100, 100, "Done", 0, 1); @@ -1365,6 +1405,233 @@ public class JFXLauncher extends Application { } } + private void handleCheckUpdates(HttpExchange exchange) { + try { + Map params = parseQuery(exchange.getRequestURI().getQuery()); + String rawName = params.get("name"); + if (rawName == null || rawName.isBlank()) { + sendJson(exchange, Map.of("success", false, "error", "Missing 'name' parameter")); + return; + } + String name = URLDecoder.decode(rawName, StandardCharsets.UTF_8); + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + if (!instance.isServerPack()) { + sendJson(exchange, Map.of("success", false, "error", "Not a server pack")); + return; + } + PackDownloader downloader = new PackDownloader(instance); + String packName = instance.getServerPackName(); + int serverVersion = downloader.getPackManifest(packName).getVersion(); + int localVersion = instance.getServerVersion(); + sendJson(exchange, Map.of( + "success", true, + "data", Map.of( + "hasUpdate", serverVersion > localVersion, + "serverVersion", serverVersion, + "localVersion", localVersion + ) + )); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleUpdate(HttpExchange exchange) { + try { + Map body = parseJson(exchange.getRequestBody()); + String name = body.get("name"); + if (name == null || name.isBlank()) { + sendJson(exchange, Map.of("success", false, "error", "Missing 'name'")); + return; + } + Instance instance = InstanceManager.getInstance(name); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + if (!instance.isServerPack()) { + sendJson(exchange, Map.of("success", false, "error", "Not a server pack")); + return; + } + PackDownloader downloader = new PackDownloader(instance); + boolean success = downloader.updatePack(instance.getServerPackName()); + if (success) { + sendJson(exchange, Map.of("success", true, "data", Map.of("version", instance.getServerVersion()))); + } else { + sendJson(exchange, Map.of("success", false, "error", "Update failed")); + } + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleWhitelistMods(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + String response = ZHttpClient.get("/whitelist/mods"); + List> mods = new ArrayList<>(); + for (String line : response.split("\n")) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("::"); + if (parts.length >= 3) { + Map mod = new HashMap<>(); + mod.put("name", parts[0].trim()); + mod.put("size", Long.parseLong(parts[1].trim())); + mod.put("hash", parts[2].trim()); + mods.add(mod); + } + } + sendJson(exchange, Map.of("success", true, "data", mods)); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleWhitelistInstallMod(HttpExchange exchange) { + try { + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + Map body = parseJson(exchange.getRequestBody()); + String instanceName = body.get("instance"); + String modName = body.get("modName"); + String modHash = body.get("modHash"); + + if (instanceName == null || modName == null || modHash == null) { + sendJson(exchange, Map.of("success", false, "error", "Missing parameters")); + return; + } + + Instance instance = InstanceManager.getInstance(instanceName); + if (instance == null) { + sendJson(exchange, Map.of("success", false, "error", "Instance not found")); + return; + } + + Path modsDir = instance.getPath().resolve("mods"); + Files.createDirectories(modsDir); + Path targetFile = modsDir.resolve(modName); + + // Download mod from server + String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName; + java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(url)) + .timeout(java.time.Duration.ofMinutes(5)) + .header("Authorization", "Bearer " + AuthManager.getAccessToken()) + .GET() + .build(); + java.net.http.HttpResponse resp = java.net.http.HttpClient.newHttpClient() + .send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile)); + + if (resp.statusCode() != 200) { + Files.deleteIfExists(targetFile); + sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode())); + return; + } + + sendJson(exchange, Map.of("success", true, "message", modName + " installed")); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + // ====================== ADMIN ====================== + + private void handleAdmin(HttpExchange exchange) { + try { + String path = exchange.getRequestURI().getPath(); + String method = exchange.getRequestMethod(); + byte[] reqBodyBytes = exchange.getRequestBody().readAllBytes(); + String body = reqBodyBytes.length > 0 ? new String(reqBodyBytes, StandardCharsets.UTF_8) : null; + + if (!api.isLoggedIn()) { + sendJson(exchange, Map.of("success", false, "error", "Not authenticated")); + return; + } + + String remotePath = path.replace("/api/admin", "/admin"); + + // Special handling for mod upload (base64 → multipart) + if ("POST".equals(method) && remotePath.equals("/admin/whitelist/mods") && body != null) { + handleAdminModUpload(exchange, body); + return; + } + + String apiMethod = method; + // Java HttpURLConnection doesn't support DELETE directly in remoteApiCall, + // so map DELETE to POST for the Python server + if ("DELETE".equals(method)) { + apiMethod = "POST"; + remotePath = remotePath + "/delete"; + } + + String response = remoteApiCall(apiMethod, remotePath, body); + sendRawJson(exchange, response); + } catch (Exception e) { + sendJson(exchange, Map.of("success", false, "error", e.getMessage())); + } + } + + private void handleAdminModUpload(HttpExchange exchange, String jsonBody) throws Exception { + @SuppressWarnings("unchecked") + Map data = gson.fromJson(jsonBody, Map.class); + String filename = data.get("filename"); + String base64Data = data.get("data"); + if (filename == null || base64Data == null) { + sendJson(exchange, Map.of("success", false, "error", "Missing filename or data")); + return; + } + + // Decode base64 + byte[] fileBytes = Base64.getDecoder().decode(base64Data); + + // Forward to Python API as multipart request + String boundary = "Boundary-" + System.currentTimeMillis(); + URL url = new URL(LAUNCHER_SERVER + "/admin/whitelist/mods"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + String token = AuthManager.getAccessToken(); + if (token != null && !token.isEmpty() && !token.equals("0")) { + conn.setRequestProperty("Authorization", "Bearer " + token); + } + conn.setConnectTimeout(30000); + conn.setReadTimeout(60000); + + String header = "--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: application/java-archive\r\n\r\n"; + String footer = "\r\n--" + boundary + "--\r\n"; + + try (OutputStream os = conn.getOutputStream()) { + os.write(header.getBytes(StandardCharsets.UTF_8)); + os.write(fileBytes); + os.write(footer.getBytes(StandardCharsets.UTF_8)); + } + + int code = conn.getResponseCode(); + String respBody; + try (InputStream is = code >= 400 ? conn.getErrorStream() : conn.getInputStream()) { + respBody = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + + if (code >= 400) { + sendJson(exchange, Map.of("success", false, "error", "Upload failed: HTTP " + code + " - " + respBody)); + } else { + sendRawJson(exchange, respBody); + } + } + private void log(String msg) { String timestamp = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")); String entry = "[" + timestamp + "] " + msg; diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java index 27974f7..c2278c6 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ProgressBar.java @@ -87,16 +87,18 @@ public class ProgressBar { } public static void finish(String message) { - try { - Class jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher"); - java.lang.reflect.Method setInProgress = jfxClass.getMethod("setInstallInProgress", boolean.class); - setInProgress.invoke(null, false); - } catch (Exception ignored) {} - System.out.println("\r" + ZAnsi.brightGreen(message + " done ✓")); System.out.flush(); } + public static void setStage(String stageName, int stageIndex, int stageCount) { + try { + Class jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher"); + java.lang.reflect.Method setStage = jfxClass.getMethod("setInstallStage", String.class, int.class, int.class); + setStage.invoke(null, stageName, stageIndex, stageCount); + } catch (Exception ignored) {} + } + public static void clearLine() { System.out.print("\r" + " ".repeat(110) + "\r"); System.out.flush(); diff --git a/launcher/launcher/src/resources/ui/index.html b/launcher/launcher/src/resources/ui/index.html index cacdb73..ad2d98b 100644 --- a/launcher/launcher/src/resources/ui/index.html +++ b/launcher/launcher/src/resources/ui/index.html @@ -78,7 +78,7 @@ @@ -185,6 +189,12 @@ + @@ -233,6 +243,111 @@ + +
+
+

Admin Panel

+
+
+ + + + + +
+
+ +
+
+
Online Clients
+
Loading...
+
+
+ +
+
+
Upload Mod
+
+ + +
+ +
+
+
Whitelist Mods
+
Loading...
+
+
+ +
+
+
User Management
+
+ + +
+
+
+ +
+ +
+
+
Pass Management
+
Loading...
+
+
+ +
+
+
Create News
+
+
+ +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
Manage News
+
Loading...
+
+
+
+
+
@@ -345,8 +460,8 @@
-
- - -
diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js index f9b4a81..3c92dfb 100644 --- a/launcher/launcher/src/resources/ui/launcher.js +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -14,6 +14,7 @@ const LOCALES = { 'pack.noDescription': 'No description available', 'stat.loaderVer': 'Loader Ver', 'stat.files': 'Files', 'stat.size': 'Size', 'playBar.play': 'Play', 'playBar.passRequired': 'Pass Required', + 'playBar.update': 'Update', 'playBar.updating': 'Updating...', 'settings.title': 'Settings', 'settings.activatePass.title': 'Activate Pass', 'settings.activatePass.desc': 'Enter your pass code to access server packs', @@ -74,6 +75,8 @@ const LOCALES = { 'toast.loginFailed': 'Login failed', 'toast.launching': 'Launching {name}...', 'toast.launchFailed': 'Launch failed', + 'toast.updated': '{name} updated to v{version}!', + 'toast.updateFailed': 'Update failed', 'toast.noPackSelected': 'Select a pack first', 'toast.launched': 'Launched! PID: {pid}', 'toast.installing': 'Installing...', @@ -116,6 +119,53 @@ const LOCALES = { 'playtime.hours': '{h}h {m}m', 'playtime.total': 'Total: {h}h', 'playtime.syncing': 'Syncing...', + 'whitelist.title': 'Additional Mods', + 'whitelist.loading': 'Loading...', + 'whitelist.empty': 'No additional mods available', + 'whitelist.error': 'Failed to load mods', + 'whitelist.install': 'Install Selected', + 'whitelist.installing': 'Installing...', + 'whitelist.installed': 'Installed', + 'whitelist.done': 'Mods installed!', + 'whitelist.selectNone': 'Select mods first', + 'whitelist.installError': 'Failed to install mod', + 'nav.admin': 'Admin', + 'admin.title': 'Admin Panel', + 'admin.clients': 'Clients', + 'admin.mods': 'Mods', + 'admin.users': 'Users', + 'admin.passes': 'Passes', + 'admin.news': 'News', + 'admin.clientsOnline': 'Online Clients', + 'admin.noClients': 'No connected clients', + 'admin.modsUpload': 'Upload Mod', + 'admin.modsUploadBtn': 'Upload', + 'admin.modsList': 'Whitelist Mods', + 'admin.modsEmpty': 'No whitelist mods', + 'admin.modsUploaded': 'Mod uploaded successfully!', + 'admin.modsDeleted': 'Mod deleted', + 'admin.usersSearch': 'User Management', + 'admin.usersSearchPlaceholder': 'Username...', + 'admin.search': 'Search', + 'admin.role': 'Role:', + 'admin.save': 'Save', + 'admin.roleUpdated': 'Role updated!', + 'admin.resetPass': 'Reset Password', + 'admin.passReset': 'Password reset! New password: {pass}', + 'admin.noUser': 'No user selected', + 'admin.passesList': 'Pass Management', + 'admin.passesEmpty': 'No passes', + 'admin.newsCreate': 'Create News', + 'admin.newsTitlePlaceholder': 'Title', + 'admin.newsBodyPlaceholder': 'Body (Markdown)', + 'admin.newsCreateBtn': 'Publish', + 'admin.newsManage': 'Manage News', + 'admin.newsCreated': 'News published!', + 'admin.newsDeleted': 'News deleted', + 'admin.newsEmpty': 'No news', + 'admin.delete': 'Delete', + 'admin.loading': 'Loading...', + 'admin.error': 'Error', }, ru: { 'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки', @@ -130,6 +180,7 @@ const LOCALES = { 'pack.noDescription': 'Описание отсутствует', 'stat.loaderVer': 'Версия загрузчика', 'stat.files': 'Файлы', 'stat.size': 'Размер', 'playBar.play': 'Играть', 'playBar.passRequired': 'Требуется проходка', + 'playBar.update': 'Обновить', 'playBar.updating': 'Обновление...', 'settings.title': 'Настройки', 'settings.activatePass.title': 'Активировать проходку', 'settings.activatePass.desc': 'Введите код проходки для доступа к серверным сборкам', @@ -190,6 +241,8 @@ const LOCALES = { 'toast.loginFailed': 'Ошибка входа', 'toast.launching': 'Запуск {name}...', 'toast.launchFailed': 'Ошибка запуска', + 'toast.updated': '{name} обновлён до v{version}!', + 'toast.updateFailed': 'Ошибка обновления', 'toast.noPackSelected': 'Сначала выберите сборку', 'toast.launched': 'Запущено! PID: {pid}', 'toast.installing': 'Установка...', @@ -232,6 +285,53 @@ const LOCALES = { 'playtime.hours': '{h}ч {m}м', 'playtime.total': 'Всего: {h}ч', 'playtime.syncing': 'Синхронизация...', + 'whitelist.title': 'Дополнительные моды', + 'whitelist.loading': 'Загрузка...', + 'whitelist.empty': 'Нет дополнительных модов', + 'whitelist.error': 'Ошибка загрузки модов', + 'whitelist.install': 'Установить выбранные', + 'whitelist.installing': 'Установка...', + 'whitelist.installed': 'Установлено', + 'whitelist.done': 'Моды установлены!', + 'whitelist.selectNone': 'Выберите моды', + 'whitelist.installError': 'Ошибка установки мода', + 'nav.admin': 'Админка', + 'admin.title': 'Панель администратора', + 'admin.clients': 'Клиенты', + 'admin.mods': 'Моды', + 'admin.users': 'Пользователи', + 'admin.passes': 'Проходки', + 'admin.news': 'Новости', + 'admin.clientsOnline': 'Онлайн клиенты', + 'admin.noClients': 'Нет подключенных клиентов', + 'admin.modsUpload': 'Загрузить мод', + 'admin.modsUploadBtn': 'Загрузить', + 'admin.modsList': 'Вайтлист моды', + 'admin.modsEmpty': 'Нет модов в вайтлисте', + 'admin.modsUploaded': 'Мод успешно загружен!', + 'admin.modsDeleted': 'Мод удалён', + 'admin.usersSearch': 'Управление пользователями', + 'admin.usersSearchPlaceholder': 'Имя пользователя...', + 'admin.search': 'Поиск', + 'admin.role': 'Роль:', + 'admin.save': 'Сохранить', + 'admin.roleUpdated': 'Роль обновлена!', + 'admin.resetPass': 'Сбросить пароль', + 'admin.passReset': 'Пароль сброшен! Новый пароль: {pass}', + 'admin.noUser': 'Пользователь не выбран', + 'admin.passesList': 'Управление проходками', + 'admin.passesEmpty': 'Нет проходок', + 'admin.newsCreate': 'Создать новость', + 'admin.newsTitlePlaceholder': 'Заголовок', + 'admin.newsBodyPlaceholder': 'Текст (Markdown)', + 'admin.newsCreateBtn': 'Опубликовать', + 'admin.newsManage': 'Управление новостями', + 'admin.newsCreated': 'Новость опубликована!', + 'admin.newsDeleted': 'Новость удалена', + 'admin.newsEmpty': 'Нет новостей', + 'admin.delete': 'Удалить', + 'admin.loading': 'Загрузка...', + 'admin.error': 'Ошибка', } }; @@ -463,6 +563,7 @@ class ZernMCLauncher { this.loadPlaytimeStats(); this.startFriendStatusHeartbeat(); this.enhanceSelects(); + this.updateAdminNav(); } async loadServerPacksList() { @@ -524,6 +625,10 @@ class ZernMCLauncher { afInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') app.submitAddFriend(); }); } + document.querySelectorAll('.admin-tab').forEach(function(t) { + t.addEventListener('click', function() { app.switchAdminTab(t.dataset.atab); }); + }); + var localeSelect = document.getElementById('locale-select'); if (localeSelect) { localeSelect.addEventListener('change', async () => { @@ -558,10 +663,36 @@ class ZernMCLauncher { const r = await this.req('/instances'); if (r.success && r.data) { this.state.instances = r.data; + this.state.updateMap = {}; + await this.checkUpdatesForServerPacks(); this.renderSidebar(); } } + async checkUpdatesForServerPacks() { + if (!this.state.instances) return; + for (const inst of this.state.instances) { + if (!inst.isServerPack) continue; + const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name)); + if (r.success && r.data) { + this.state.updateMap[inst.name] = r.data.hasUpdate; + } + } + } + + async checkUpdateForPack(inst) { + if (!inst || !inst.isServerPack) return; + const r = await this.req('/check-updates?name=' + encodeURIComponent(inst.name)); + if (r.success && r.data) { + if (!this.state.updateMap) this.state.updateMap = {}; + this.state.updateMap[inst.name] = r.data.hasUpdate; + } + } + + hasUpdate(inst) { + return inst && inst.isServerPack && this.state.updateMap && this.state.updateMap[inst.name] === true; + } + renderSidebar() { const serverList = document.getElementById('server-packs-list'); const localList = document.getElementById('local-packs-list'); @@ -578,15 +709,17 @@ class ZernMCLauncher { const el = document.createElement('div'); el.className = 'pack-entry' + (this.state.selectedPack && this.state.selectedPack.name === inst.name ? ' selected' : ''); + const hasUpd = this.hasUpdate(inst); el.innerHTML = `
${isZern ? '' : '' } + ${hasUpd ? '' : ''}
`; @@ -611,6 +744,10 @@ class ZernMCLauncher { // Enable play button immediately so user can click it right away this.setPlayBtnText('playBar.play', false); this.showPackDetail(inst); + // Check for updates asynchronously + this.checkUpdateForPack(inst).then(() => { + if (this.state.selectedPack === inst) this.showPackDetail(inst); + }); // Refresh account in background to check pass status this.refreshAccount().then(() => { if (this.state.selectedPack === inst) { @@ -673,7 +810,7 @@ class ZernMCLauncher { serverTag.classList.add('hidden'); } document.getElementById('detail-loader-ver').textContent = inst.loaderVersion || '-'; - document.getElementById('detail-files').textContent = inst.filesCount || '0'; + document.getElementById('detail-files').textContent = '...'; var playtimeEl = document.getElementById('detail-playtime'); var stats = this.state.playtimeStats; @@ -701,6 +838,8 @@ class ZernMCLauncher { var passActive = this.state.account ? this.state.account.passActive : false; if (inst.isServerPack && !passActive) { this.setPlayBtnText('playBar.passRequired', true); + } else if (this.hasUpdate(inst)) { + this.setPlayBtnText('playBar.update', false); } else { this.setPlayBtnText('playBar.play', false); } @@ -723,6 +862,15 @@ class ZernMCLauncher { // Load pack info from local files this.loadPackInfo(inst.name); + + // Load whitelist mods for server packs + const whitelistSection = document.getElementById('whitelist-mods-section'); + if (inst.isServerPack) { + this.loadWhitelistMods(inst.name); + whitelistSection.classList.remove('hidden'); + } else { + whitelistSection.classList.add('hidden'); + } } async loadPackInfo(name) { @@ -739,6 +887,348 @@ class ZernMCLauncher { } } + async loadWhitelistMods(instanceName) { + const listEl = document.getElementById('whitelist-mods-list'); + listEl.innerHTML = '
' + t('whitelist.loading') + '
'; + const r = await this.req('/whitelist/mods'); + if (!r.success || !r.data) { + listEl.innerHTML = '
' + (r.error || t('whitelist.error')) + '
'; + return; + } + if (r.data.length === 0) { + listEl.innerHTML = '
' + t('whitelist.empty') + '
'; + return; + } + var html = ''; + r.data.forEach(function(mod) { + var sizeStr = mod.size > 1048576 ? (mod.size / 1048576).toFixed(1) + ' MB' : Math.ceil(mod.size / 1024) + ' KB'; + html += ''; + }, this); + html += ''; + listEl.innerHTML = html; + } + + async installSelectedWhitelistMods(instanceName) { + var checkboxes = document.querySelectorAll('.whitelist-mod-checkbox:checked'); + if (checkboxes.length === 0) { this.toast(t('whitelist.selectNone'), 'warning'); return; } + var btn = document.querySelector('.whitelist-install-btn'); + btn.disabled = true; + btn.textContent = t('whitelist.installing'); + for (var i = 0; i < checkboxes.length; i++) { + var cb = checkboxes[i]; + var modName = cb.getAttribute('data-name'); + var modHash = cb.getAttribute('data-hash'); + var r = await this.req('/whitelist/mods/install', { + method: 'POST', + body: JSON.stringify({ instance: instanceName, modName: modName, modHash: modHash }) + }); + if (r.success) { + cb.checked = false; + cb.parentElement.querySelector('.whitelist-mod-info').innerHTML += '
' + t('whitelist.installed') + '
'; + } else { + this.toast(r.error || t('whitelist.installError'), 'error'); + } + } + btn.disabled = false; + btn.innerHTML = 'Install Selected'; + this.toast(t('whitelist.done'), 'success'); + } + + // ==================== ADMIN ==================== + updateAdminNav() { + var a = this.state.account; + var btn = document.getElementById('admin-nav-btn'); + if (!btn) return; + if (a && a.role >= 2) { + btn.classList.remove('hidden'); + } else { + btn.classList.add('hidden'); + } + } + + switchAdminTab(tab) { + document.querySelectorAll('.admin-tab').forEach(function(t) { + t.classList.toggle('active', t.dataset.atab === tab); + }); + document.querySelectorAll('.admin-tab-content').forEach(function(c) { + c.classList.toggle('active', c.id === 'atab-' + tab); + }); + if (tab === 'clients') this.adminLoadClients(); + if (tab === 'mods') this.adminLoadMods(); + if (tab === 'users') this.adminClearUser(); + if (tab === 'passes') this.adminLoadPasses(); + if (tab === 'news') this.adminLoadNewsList(); + } + + // --- Clients --- + async adminLoadClients() { + var el = document.getElementById('admin-clients-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/clients'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + ': ' + (r.error || '') + '
'; + return; + } + var clients = r.clients || r.data || []; + if (!clients.length) { + el.innerHTML = '
' + t('admin.noClients') + '
'; + return; + } + var html = ''; + clients.forEach(function(c) { + html += '
' + + '' + app.esc(c.username || c.name || '?') + '' + + 'IP: ' + app.esc(c.ip || '-') + '' + + '
'; + }); + el.innerHTML = html; + setTimeout(function() { app.adminLoadClients(); }, 5000); + } + + // --- Mods --- + async adminLoadMods() { + var el = document.getElementById('admin-mods-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/whitelist/mods'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var mods = r.mods || r.data || []; + if (!mods.length) { + el.innerHTML = '
' + t('admin.modsEmpty') + '
'; + return; + } + var html = ''; + mods.forEach(function(m) { + var sizeStr = m.size > 1048576 ? (m.size / 1048576).toFixed(1) + ' MB' : Math.ceil(m.size / 1024) + ' KB'; + html += '
' + + '' + app.esc(m.name) + '' + + '' + sizeStr + '' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + async adminUploadMod() { + var input = document.getElementById('admin-mod-file'); + var status = document.getElementById('admin-mod-status'); + if (!input || !input.files || !input.files[0]) { this.toast('Select a file', 'error'); return; } + var file = input.files[0]; + if (!file.name.endsWith('.jar')) { this.toast('Only .jar files', 'error'); return; } + var reader = new FileReader(); + reader.onload = async function(e) { + var base64 = e.target.result.split(',')[1]; + var r = await app.req('/admin/whitelist/mods', { + method: 'POST', + body: JSON.stringify({ filename: file.name, data: base64 }) + }); + status.classList.remove('hidden'); + if (r.success) { + status.textContent = t('admin.modsUploaded'); + status.className = 'admin-status success'; + input.value = ''; + app.adminLoadMods(); + } else { + status.textContent = r.error || t('admin.error'); + status.className = 'admin-status error'; + } + }; + reader.readAsDataURL(file); + } + + async adminDeleteMod(hash) { + var r = await this.req('/admin/whitelist/mods/' + hash, { method: 'DELETE' }); + if (r.success) { + this.toast(t('admin.modsDeleted'), 'success'); + this.adminLoadMods(); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + // --- Users --- + async adminSearchUser() { + var input = document.getElementById('admin-user-search'); + var results = document.getElementById('admin-user-results'); + var query = input.value.trim(); + if (!query) return; + results.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/users/search', { + method: 'POST', + body: JSON.stringify({ search: query }) + }); + if (!r.success) { + results.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var users = r.users || r.data || []; + if (!users.length) { + results.innerHTML = '
' + t('admin.noUser') + '
'; + return; + } + var html = ''; + users.forEach(function(u) { + html += '
' + + '' + app.esc(u.username) + '' + + '' + (u.role_name || 'Role ' + (u.role || 0)) + '' + + '
'; + }); + results.innerHTML = html; + } + + adminClearUser() { + document.getElementById('admin-user-results').innerHTML = ''; + document.getElementById('admin-user-detail').classList.add('hidden'); + document.getElementById('admin-user-search').value = ''; + } + + adminSelectUser(id, username, role) { + var detail = document.getElementById('admin-user-detail'); + document.getElementById('admin-user-detail-name').textContent = username; + document.getElementById('admin-user-role-select').value = String(role); + detail.dataset.userId = id; + detail.dataset.username = username; + detail.classList.remove('hidden'); + } + + async adminSetRole() { + var detail = document.getElementById('admin-user-detail'); + var userId = detail.dataset.userId; + var username = detail.dataset.username; + var role = parseInt(document.getElementById('admin-user-role-select').value); + if (!userId) { this.toast(t('admin.noUser'), 'error'); return; } + var r = await this.req('/admin/users/role', { + method: 'POST', + body: JSON.stringify({ user_id: parseInt(userId), role: role, username: username }) + }); + if (r.success) { + this.toast(t('admin.roleUpdated'), 'success'); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + async adminResetPassword() { + var detail = document.getElementById('admin-user-detail'); + var userId = detail.dataset.userId; + var username = detail.dataset.username; + if (!userId) { this.toast(t('admin.noUser'), 'error'); return; } + var r = await this.req('/admin/users/reset-password', { + method: 'POST', + body: JSON.stringify({ user_id: parseInt(userId), username: username }) + }); + if (r.success) { + this.toast(tr('admin.passReset', null, {pass: r.new_password || r.password || ''}), 'success'); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + + // --- Passes --- + async adminLoadPasses() { + var el = document.getElementById('admin-passes-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/admin/passes'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var passes = r.passes || r.data || []; + if (!passes.length) { + el.innerHTML = '
' + t('admin.passesEmpty') + '
'; + return; + } + var html = ''; + passes.forEach(function(p) { + html += '
' + + '' + app.esc(p.code || p.name || '?') + '' + + '' + (p.username ? app.esc(p.username) : '') + '' + + '
'; + }); + el.innerHTML = html; + } + + // --- News --- + async adminLoadNewsList() { + var el = document.getElementById('admin-news-list'); + if (!el) return; + el.innerHTML = '
' + t('admin.loading') + '
'; + var r = await this.req('/news'); + if (!r.success) { + el.innerHTML = '
' + t('admin.error') + '
'; + return; + } + var newsList = []; + try { newsList = (typeof r.data === 'string' ? JSON.parse(r.data) : r.data).news || []; } catch(e) {} + if (!newsList.length) { + el.innerHTML = '
' + t('admin.newsEmpty') + '
'; + return; + } + var html = ''; + newsList.forEach(function(item, idx) { + html += '
' + + '
' + + '
' + app.esc(item.title) + '
' + + '
' + app.esc(item.type) + ' \u00b7 ' + app.esc(item.version || '') + '
' + + '
' + + '
' + + '' + + '
'; + }); + el.innerHTML = html; + } + + async adminCreateNews() { + var title = document.getElementById('admin-news-title').value.trim(); + var type = document.getElementById('admin-news-type').value; + var version = document.getElementById('admin-news-version').value.trim(); + var body = document.getElementById('admin-news-body').value.trim(); + var status = document.getElementById('admin-news-status'); + if (!title || !body) { this.toast('Fill in title and body', 'error'); return; } + var r = await this.req('/admin/news', { + method: 'POST', + body: JSON.stringify({ title: title, type: type, version: version, body: body }) + }); + status.classList.remove('hidden'); + if (r.success) { + status.textContent = t('admin.newsCreated'); + status.className = 'admin-status success'; + document.getElementById('admin-news-title').value = ''; + document.getElementById('admin-news-version').value = ''; + document.getElementById('admin-news-body').value = ''; + this.adminLoadNewsList(); + } else { + status.textContent = r.error || t('admin.error'); + status.className = 'admin-status error'; + } + } + + async adminDeleteNews(idx) { + var r = await this.req('/admin/news/delete', { + method: 'POST', + body: JSON.stringify({ index: parseInt(idx) }) + }); + if (r.success) { + this.toast(t('admin.newsDeleted'), 'success'); + this.adminLoadNewsList(); + } else { + this.toast(r.error || t('admin.error'), 'error'); + } + } + // ==================== NEWS ==================== async loadNews() { const r = await this.req('/news'); @@ -844,6 +1334,10 @@ class ZernMCLauncher { this.toast(t('toast.noPackSelected'), 'warning'); return; } + if (this.hasUpdate(inst)) { + await this.updateSelectedPack(); + return; + } this.toast(tr('toast.launching', null, {name: inst.name}), 'info'); const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) }); if (r.success) { @@ -854,52 +1348,87 @@ class ZernMCLauncher { } } + async updateSelectedPack() { + const inst = this.state.selectedPack; + if (!inst) return; + this.setPlayBtnText('playBar.updating', true); + const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }) }); + if (r.success) { + this.toast(tr('toast.updated', null, {name: inst.name, version: String(r.data?.version || '')}), 'success'); + if (this.state.updateMap) { + this.state.updateMap[inst.name] = false; + } + await this.loadInstances(); + } else { + this.toast(r.error || t('toast.updateFailed'), 'error'); + this.setPlayBtnText('playBar.update', false); + } + } + // ==================== INSTALL ==================== async showInstallModal() { document.getElementById('install-modal').classList.remove('hidden'); - document.getElementById('zernmc-pack-select').innerHTML = ''; - document.getElementById('mc-version-select').innerHTML = ''; - - const packs = await this.req('/packs'); const zernmcSel = document.getElementById('zernmc-pack-select'); - if (packs.success && packs.data && packs.data.length > 0) { - if (this.state.account && this.state.account.passActive) { - zernmcSel.innerHTML = ''; - zernmcSel.disabled = false; - packs.data.forEach(p => { - const o = document.createElement('option'); - o.value = p.name; - o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')'; - zernmcSel.appendChild(o); - }); - } else { - zernmcSel.innerHTML = ''; + const mcSel = document.getElementById('mc-version-select'); + if (zernmcSel) { + zernmcSel.innerHTML = ''; + } + if (mcSel) { + mcSel.innerHTML = ''; + } + + try { + const packs = await this.req('/packs'); + if (zernmcSel && packs.success && Array.isArray(packs.data) && packs.data.length > 0) { + if (this.state.account && this.state.account.passActive) { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = false; + packs.data.forEach(p => { + const o = document.createElement('option'); + o.value = p.name; + o.textContent = (p.displayName || p.name) + ' (' + (p.version || '') + ')'; + zernmcSel.appendChild(o); + }); + } else { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = true; + } + } else if (zernmcSel) { + zernmcSel.innerHTML = ''; + zernmcSel.disabled = true; + } + + if (this.state.account && !this.state.account.passActive) { + const zernmcTab = document.querySelector('[data-tab="zernmc"]'); + if (zernmcTab) zernmcTab.style.opacity = '0.5'; + } else { + const zernmcTab = document.querySelector('[data-tab="zernmc"]'); + if (zernmcTab) zernmcTab.style.opacity = '1'; + } + } catch (e) { + if (zernmcSel) { + zernmcSel.innerHTML = ''; zernmcSel.disabled = true; } - } else { - zernmcSel.innerHTML = ''; - zernmcSel.disabled = true; } + if (mcSel) this.enhanceSelects(); - if (this.state.account && !this.state.account.passActive) { - document.querySelector('[data-tab="zernmc"]').style.opacity = '0.5'; - } else { - document.querySelector('[data-tab="zernmc"]').style.opacity = '1'; - } - - this.enhanceSelects(); - - const mc = await this.req('/mc-versions'); - const mcSel = document.getElementById('mc-version-select'); - if (mc.success && mc.data) { - mcSel.innerHTML = ''; - mc.data.forEach(v => { - const o = document.createElement('option'); - o.value = v; o.textContent = v; - mcSel.appendChild(o); - }); - } else { - mcSel.innerHTML = ''; + if (mcSel) { + try { + const mc = await this.req('/mc-versions'); + if (mc.success && Array.isArray(mc.data)) { + mcSel.innerHTML = ''; + mc.data.forEach(v => { + const o = document.createElement('option'); + o.value = v; o.textContent = v; + mcSel.appendChild(o); + }); + } else { + mcSel.innerHTML = ''; + } + } catch (e) { + mcSel.innerHTML = ''; + } } this.enhanceSelects(); } @@ -1054,9 +1583,9 @@ class ZernMCLauncher { async installZernMCPack() { const packName = document.getElementById('zernmc-pack-select').value; - const instanceName = document.getElementById('zernmc-instance-name').value.trim(); if (!packName) { this.toast(t('select.selectPack'), 'error'); return; } - if (!instanceName) { this.toast(t('install.enterName'), 'error'); return; } + // Use server pack name as instance name (no local naming) + const instanceName = packName; const r = await this.req('/install', { method: 'POST', @@ -1288,6 +1817,8 @@ class ZernMCLauncher { closeAddFriend() { document.getElementById('add-friend-modal').classList.add('hidden'); + document.getElementById('add-friend-input').value = ''; + document.getElementById('add-friend-error').classList.add('hidden'); } async submitAddFriend() { diff --git a/launcher/launcher/src/resources/ui/style.css b/launcher/launcher/src/resources/ui/style.css index fd8159b..5330733 100644 --- a/launcher/launcher/src/resources/ui/style.css +++ b/launcher/launcher/src/resources/ui/style.css @@ -92,7 +92,9 @@ body { background: var(--bg-elevated); padding: 0 4px; } .field input:focus + label, -.field input:not(:placeholder-shown) + label { +.field input:not(:placeholder-shown) + label, +.field textarea:focus + label, +.field textarea:not(:placeholder-shown) + label { top: 0; font-size: 11px; color: var(--accent); } .field input { @@ -174,7 +176,7 @@ body { padding: 16px 12px; } -.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 20px; overflow: hidden; } +.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } .sidebar-brand { display: flex; align-items: center; gap: 10px; @@ -185,12 +187,15 @@ body { .sidebar-brand-ver { font-size: 11px; color: var(--text-muted); } .sidebar-nav { - display: flex; justify-content: space-between; gap: 4px; + display: flex; flex-direction: column; gap: 4px; padding-bottom: 16px; border-bottom: 1px solid var(--border); } -.sidebar-nav-left, .sidebar-nav-right { +.sidebar-nav-primary, .sidebar-nav-secondary { display: flex; gap: 4px; } +.sidebar-nav-primary .nav-btn, .sidebar-nav-secondary .nav-btn { + flex: 1; +} .nav-btn { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 8px; background: transparent; border: 1px solid transparent; @@ -226,6 +231,16 @@ body { .pack-entry:hover { background: var(--bg-card); } .pack-entry.selected { background: var(--accent-soft); border-color: rgba(233,69,96,0.25); } +.pack-entry-icon { position: relative; } +.pack-entry-icon .update-badge { + position: absolute; top: -3px; right: -3px; width: 10px; height: 10px; + border-radius: 50%; background: #e94560; border: 2px solid var(--bg-main, #0f0f1a); +} +.update-label { + display: inline-block; font-size: 10px; font-weight: 600; color: #e94560; + background: rgba(233,69,96,0.12); padding: 0 5px; border-radius: 3px; + margin-left: 4px; vertical-align: middle; line-height: 16px; +} .pack-entry-icon { width: 32px; height: 32px; border-radius: 6px; display: flex; align-items: center; justify-content: center; @@ -552,7 +567,7 @@ body { .modal { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg); width: 90%; max-width: 480px; - max-height: 85vh; overflow-y: auto; box-shadow: var(--shadow); + max-height: 85vh; box-shadow: var(--shadow); animation: floatIn 0.3s ease; } .modal-head { @@ -668,12 +683,11 @@ body { } @media (max-width: 700px) { .sidebar { width: 56px; min-width: 56px; } - .sidebar-brand-text, .sidebar-nav .nav-btn span, + .sidebar-brand-text, .sidebar-nav-primary .nav-btn span, .sidebar-nav-secondary .nav-btn span, .section-header, .pack-entry-info, .user-info, .sidebar-bottom .user-card .btn-icon:first-child { display: none; } .sidebar-brand { justify-content: center; padding: 8px; } - .sidebar-nav { flex-direction: column; } - .sidebar-nav-left, .sidebar-nav-right { flex-direction: column; } + .sidebar-nav-primary, .sidebar-nav-secondary { flex-direction: column; } .nav-btn { padding: 8px; } .pack-entry { justify-content: center; padding: 8px; } .content { padding: 12px; } @@ -756,8 +770,114 @@ body { .friend-request-text { font-size: 11px; color: var(--text-muted); } .friend-request-actions { display: flex; gap: 6px; flex-shrink: 0; } +/* ========== WHITELIST MODS ========== */ +.whitelist-mods-section { + margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); +} +.whitelist-mods-section .section-header { + font-size: 11px; font-weight: 600; text-transform: uppercase; + color: var(--text-muted); padding: 4px; margin-bottom: 8px; letter-spacing: 0.5px; +} +.whitelist-mods-list { display: flex; flex-direction: column; gap: 4px; } +.whitelist-loading { + text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; +} +.whitelist-mod-item { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + border-radius: var(--radius-sm); transition: var(--transition); + background: var(--bg-card); +} +.whitelist-mod-item:hover { background: var(--bg-card-hover); } +.whitelist-mod-checkbox { + width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); +} +.whitelist-mod-info { flex: 1; min-width: 0; } +.whitelist-mod-name { font-size: 13px; font-weight: 500; color: var(--text); } +.whitelist-mod-size { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.whitelist-mod-status { font-size: 11px; color: var(--success); } +.whitelist-install-btn { + width: 100%; padding: 10px; margin-top: 8px; + background: var(--accent-soft); border: 1px solid rgba(233,69,96,0.3); + border-radius: var(--radius-sm); color: var(--accent); font-size: 13px; + font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition); +} +.whitelist-install-btn:hover { background: var(--accent); color: #fff; } +.whitelist-install-btn:disabled { opacity: 0.4; cursor: default; } + /* ========== MODAL SM ========== */ .modal-sm { max-width: 360px; } /* ========== BADGE SM ========== */ .badge-sm { font-size: 10px; padding: 2px 6px; } + +/* ========== ADMIN PANEL ========== */ +.admin-tabs { + display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; +} +.admin-tab { + padding: 8px 14px; background: transparent; border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12px; + font-weight: 500; cursor: pointer; font-family: var(--font); transition: var(--transition); +} +.admin-tab.active { background: var(--accent-soft); border-color: rgba(233,69,96,0.3); color: var(--accent); } +.admin-tab:hover:not(.active) { background: var(--bg-card); color: var(--text-secondary); } +.admin-tab-content { display: none; flex-direction: column; gap: 12px; } +.admin-tab-content.active { display: flex; } +.admin-card { + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 16px; +} +.admin-card-header { + font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; + border-bottom: 1px solid var(--border); color: var(--text); +} +.admin-list { display: flex; flex-direction: column; gap: 4px; } +.admin-loading, .admin-empty { + text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; +} +.admin-row { + display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; +} +.admin-input { + flex: 1; padding: 8px 12px; background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text); font-size: 13px; outline: none; font-family: var(--font); +} +.admin-input:focus { border-color: var(--accent); } +.admin-select { + padding: 6px 10px; background: var(--bg-surface); border: 1px solid var(--border-light); + border-radius: var(--radius-sm); color: var(--text); font-size: 12px; outline: none; cursor: pointer; font-family: var(--font); +} +.admin-textarea { + width: 100%; min-height: 100px; padding: 10px; background: var(--bg-surface); + border: 1px solid var(--border-light); border-radius: var(--radius-sm); color: var(--text); + font-size: 13px; outline: none; font-family: var(--font); resize: vertical; +} +.admin-textarea:focus { border-color: var(--accent); } +.admin-file-input { + font-size: 12px; color: var(--text-secondary); font-family: var(--font); +} +.admin-status { + font-size: 12px; padding: 8px 12px; border-radius: var(--radius-sm); margin-top: 8px; +} +.admin-status.success { background: rgba(74,222,128,0.1); color: var(--success); } +.admin-status.error { background: rgba(248,113,113,0.1); color: var(--error); } +.admin-item { + display: flex; align-items: center; gap: 10px; padding: 8px 10px; + border-radius: var(--radius-sm); transition: var(--transition); font-size: 13px; +} +.admin-item:hover { background: var(--bg-card-hover); } +.admin-item-name { flex: 1; color: var(--text); } +.admin-item-meta { font-size: 11px; color: var(--text-muted); } +.admin-item-actions { display: flex; gap: 6px; flex-shrink: 0; } +.admin-form { display: flex; flex-direction: column; gap: 8px; } +.admin-btn-danger { color: var(--error) !important; } +.admin-btn-danger:hover { background: rgba(248,113,113,0.1) !important; border-color: rgba(248,113,113,0.3) !important; } +.admin-news-item { + display: flex; align-items: flex-start; gap: 10px; padding: 10px; + border-radius: var(--radius-sm); transition: var(--transition); border: 1px solid var(--border); + margin-bottom: 6px; +} +.admin-news-item:hover { background: var(--bg-card-hover); } +.admin-news-info { flex: 1; min-width: 0; } +.admin-news-title { font-size: 13px; font-weight: 500; } +.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } diff --git a/server/admin_router.py b/server/admin_router.py index c367ac6..12ee682 100644 --- a/server/admin_router.py +++ b/server/admin_router.py @@ -1,13 +1,15 @@ # admin_router.py -from fastapi import APIRouter, HTTPException, Depends, Request, status +from fastapi import APIRouter, HTTPException, Depends, Request, status, UploadFile, File from pydantic import BaseModel, Field from typing import Optional, List import structlog import time import secrets +import hashlib from datetime import datetime +from pathlib import Path -from auth import get_db, require_role, log_audit, get_current_user +from auth import get_db, require_role, log_audit, get_current_user, hash_password from roles import ( ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions, ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR @@ -548,6 +550,333 @@ async def get_admin_stats( } +# ====================== WHITELIST MODS ====================== + +WHITELIST_DIR = Path(__file__).parent / "whitelist" +WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt" + + +def _read_mods_list() -> list[dict]: + """Parse mods.txt into list of {name, size, hash}""" + if not WHITELIST_MODS_FILE.exists(): + return [] + mods = [] + for line in WHITELIST_MODS_FILE.read_text("utf-8").strip().split("\n"): + line = line.strip() + if not line: + continue + parts = line.split("::") + if len(parts) >= 3: + mods.append({ + "name": parts[0].strip(), + "size": int(parts[1].strip()), + "hash": parts[2].strip() + }) + return mods + + +def _write_mods_list(mods: list[dict]): + """Write mods list to mods.txt""" + lines = [f"{m['name']} :: {m['size']} :: {m['hash']}" for m in mods] + WHITELIST_DIR.mkdir(parents=True, exist_ok=True) + WHITELIST_MODS_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +@router.get("/whitelist/mods") +async def admin_list_whitelist_mods(current_user: dict = Depends(require_role(ROLE_ELDER))): + """List all whitelist mods (JSON)""" + return {"mods": _read_mods_list()} + + +@router.post("/whitelist/mods") +async def add_whitelist_mod( + file: UploadFile = File(...), + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Upload a mod file — auto-computes hash, records size, adds to whitelist""" + if not file.filename or not file.filename.endswith(".jar"): + raise HTTPException(400, "Only .jar files are allowed") + + contents = await file.read() + if len(contents) == 0: + raise HTTPException(400, "Empty file") + + file_hash = hashlib.sha256(contents).hexdigest() + file_size = len(contents) + + WHITELIST_DIR.mkdir(parents=True, exist_ok=True) + + mod_path = WHITELIST_DIR / file_hash + if mod_path.exists(): + raise HTTPException(409, f"Mod with hash {file_hash} already exists") + + mod_path.write_bytes(contents) + + mods = _read_mods_list() + mods.append({"name": file.filename, "size": file_size, "hash": file_hash}) + _write_mods_list(mods) + + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "whitelist_add", f"Added {file.filename} ({file_hash})", ip) + + return {"success": True, "name": file.filename, "size": file_size, "hash": file_hash} + + +@router.delete("/whitelist/mods/{mod_hash}") +async def remove_whitelist_mod( + mod_hash: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Remove a mod from whitelist by hash""" + if ".." in mod_hash: + raise HTTPException(400, "Invalid hash") + + mod_path = WHITELIST_DIR / mod_hash + deleted_file = False + if mod_path.exists() and mod_path.is_file(): + mod_path.unlink() + deleted_file = True + + mods = _read_mods_list() + filtered = [m for m in mods if m["hash"] != mod_hash] + if len(filtered) == len(mods) and not deleted_file: + raise HTTPException(404, "Mod not found") + + _write_mods_list(filtered) + + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "whitelist_remove", f"Removed mod with hash {mod_hash}", ip) + + return {"success": True, "hash": mod_hash, "file_deleted": deleted_file} + + +# ====================== ADMIN CLIENTS, USERS, PASSES ====================== + +@router.get("/clients") +async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))): + """List online clients""" + with get_db() as conn: + rows = conn.execute(""" + SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address + FROM users u + LEFT JOIN user_status us ON u.id = us.user_id + LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1 + WHERE us.is_online = 1 + GROUP BY u.id + ORDER BY us.last_seen DESC + """).fetchall() + clients = [] + for row in rows: + clients.append({ + "id": row["id"], + "username": row["username"], + "online": bool(row["is_online"]), + "current_pack": row["current_pack"] or "", + "last_seen": str(row["last_seen"]) if row["last_seen"] else None, + "ip": row["ip_address"] or "" + }) + return {"success": True, "clients": clients} + + +class UserSearchRequest(BaseModel): + search: str + + +@router.post("/users/search") +async def admin_search_users( + req: UserSearchRequest, + current_user: dict = Depends(require_role(ROLE_MODERATOR)), +): + """Search users by username""" + if not req.search.strip(): + return {"users": []} + with get_db() as conn: + rows = conn.execute(""" + SELECT id, username, uuid, role, created_at, last_login, is_active + FROM users WHERE username LIKE ? + ORDER BY username LIMIT 20 + """, (f"%{req.search.strip()}%",)).fetchall() + users = [] + for row in rows: + users.append({ + "id": row["id"], + "username": row["username"], + "uuid": row["uuid"], + "role": row["role"], + "role_name": ROLE_NAMES.get(row["role"], "Unknown"), + "created_at": row["created_at"], + "last_login": row["last_login"], + "is_active": bool(row["is_active"]) + }) + return {"success": True, "users": users} + + +class RoleUpdateRequest(BaseModel): + user_id: int + role: int = Field(..., ge=0, le=4) + username: Optional[str] = None + + +@router.post("/users/role") +async def admin_update_user_role( + body: RoleUpdateRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Update user role""" + ip = request.client.host if request.client else "unknown" + with get_db() as conn: + target = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + (body.user_id,) + ).fetchone() + if not target: + raise HTTPException(404, "User not found") + if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Cannot change creator role") + if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Cannot modify equal or higher role") + if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Cannot assign role above your own") + if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR: + raise HTTPException(403, "Only creator can assign Elder") + + old_role = target["role"] + conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, body.user_id)) + conn.commit() + + log_audit(current_user["id"], "role_change", + f"Changed role of {target['username']} from {old_role} to {body.role}", ip) + + return {"success": True, "user_id": body.user_id, "username": target["username"], + "old_role": old_role, "new_role": body.role} + + +class ResetPasswordRequest(BaseModel): + user_id: int + username: Optional[str] = None + + +@router.post("/users/reset-password") +async def admin_reset_password( + body: ResetPasswordRequest, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Reset user password""" + ip = request.client.host if request.client else "unknown" + new_password = secrets.token_hex(8) + pw_hash = hash_password(new_password) + with get_db() as conn: + target = conn.execute( + "SELECT id, username FROM users WHERE id = ?", + (body.user_id,) + ).fetchone() + if not target: + raise HTTPException(404, "User not found") + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", + (pw_hash, body.user_id)) + conn.commit() + log_audit(current_user["id"], "password_reset", + f"Reset password for {target['username']}", ip) + return {"success": True, "new_password": new_password, "username": target["username"]} + + +@router.get("/passes") +async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODERATOR))): + """List all passes""" + with get_db() as conn: + rows = conn.execute(""" + SELECT p.code, p.is_active, p.owner, p.activated_by, p.activated_at, + p.expires_at, p.max_uses, p.uses, u.username as activated_username + FROM passes p + LEFT JOIN users u ON p.activated_by = u.id + ORDER BY p.activated_at DESC + """).fetchall() + passes = [] + for row in rows: + passes.append({ + "code": row["code"], + "owner": row["owner"], + "is_active": bool(row["is_active"]), + "activated_by": row["activated_by"], + "activated_username": row["activated_username"], + "activated_at": row["activated_at"], + "expires_at": row["expires_at"], + "max_uses": row["max_uses"], + "uses": row["uses"] + }) + return {"success": True, "passes": passes} + + +@router.post("/whitelist/mods/{mod_hash}/delete") +async def admin_delete_whitelist_mod_adapter( + mod_hash: str, + current_user: dict = Depends(require_role(ROLE_ELDER)), + request: Request = None, +): + """Delete whitelist mod (adapter for POST-based deletion)""" + return await remove_whitelist_mod(mod_hash, current_user, request) + + +# ====================== ADMIN NEWS ====================== + +ADMIN_NEWS_DIR = Path(__file__).parent / "news" + + +class CreateNewsRequest(BaseModel): + title: str + type: str = "Update" + version: str = "" + body: str + + +class DeleteNewsRequest(BaseModel): + index: int + + +@router.post("/news") +async def admin_create_news( + body: CreateNewsRequest, + current_user: dict = Depends(require_role(ROLE_MODERATOR)), + request: Request = None, +): + """Create a news item""" + if not body.title.strip() or not body.body.strip(): + raise HTTPException(400, "Title and body are required") + ADMIN_NEWS_DIR.mkdir(parents=True, exist_ok=True) + news_id = str(int(time.time())) + file_path = ADMIN_NEWS_DIR / f"{news_id}.txt" + content = f"{body.title.strip()}\n{body.type.strip()}\n{body.version.strip()}\n{body.body.strip()}" + file_path.write_text(content, encoding="utf-8") + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "news_create", f"Created news: {body.title}", ip) + return {"success": True, "id": news_id} + + +@router.post("/news/delete") +async def admin_delete_news( + body: DeleteNewsRequest, + current_user: dict = Depends(require_role(ROLE_MODERATOR)), + request: Request = None, +): + """Delete a news item by displayed index (0 = newest)""" + if not ADMIN_NEWS_DIR.exists(): + raise HTTPException(404, "No news to delete") + files = sorted(ADMIN_NEWS_DIR.iterdir()) + news_files = [f for f in files if f.is_file() and f.suffix == ".txt"] + if body.index < 0 or body.index >= len(news_files): + raise HTTPException(404, "News not found") + # Displayed list is reversed (newest first), so map index + target = news_files[len(news_files) - 1 - body.index] + target.unlink() + ip = request.client.host if request.client else "unknown" + log_audit(current_user["id"], "news_delete", f"Deleted news: {target.stem}", ip) + return {"success": True, "id": target.stem} + + @router.get("/me") async def get_my_info(current_user: dict = Depends(get_current_user)): """Информация о текущем пользователе с правами""" diff --git a/server/main.py b/server/main.py index cb2f84e..b486317 100644 --- a/server/main.py +++ b/server/main.py @@ -43,6 +43,7 @@ manifest_cache = TTLCache(maxsize=100, ttl=300) BUILDS_DIR = Path("builds") VERSIONS_DIR = BUILDS_DIR / "versions" +WHITELIST_DIR = Path(__file__).parent / "whitelist" # Mirror configuration LAUNCHER_MIRRORS = { @@ -1256,36 +1257,42 @@ def generate_launcher_builds_meta(): meta_path = BUILDS_DIR / "meta.json" - # Check if meta exists and is fresh - if meta_path.exists(): - try: - with open(meta_path, 'r', encoding='utf-8') as f: - existing = json.load(f) - if existing.get("version") == version: - logger.debug("Launcher meta.json already exists and is current") - return - except Exception: - pass + # Always regenerate to pick up file changes (new JS, CSS, etc.) + # The version check alone is insufficient — same version can have different files + # Generate new meta files = [] try: for file_path in BUILDS_DIR.rglob("*"): - if file_path.is_file() and file_path.name not in ["meta.json"]: - rel_path = str(file_path.relative_to(BUILDS_DIR)) - stat = file_path.stat() - - # Calculate hash - sha256_hash = hashlib.sha256() - with open(file_path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256_hash.update(chunk) - - files.append({ - "path": rel_path, - "size": stat.st_size, - "hash": f"sha256:{sha256_hash.hexdigest()}" - }) + if not file_path.is_file(): + continue + rel_path = str(file_path.relative_to(BUILDS_DIR)) + + # Only include runtime files needed for launcher updates + # Skip: JRE, JavaFX, old ZIPs, extracted versions, docs, root JARs + if rel_path.startswith(("jre21/", "lib/", "lib-javafx/", "versions/", "libs/")): + continue + if rel_path.endswith(".zip") or rel_path in ("README.txt", "build.version"): + continue + if file_path.name in ("meta.json",): + continue + # Skip root-level JARs — only bin/*.jar is needed at runtime + if rel_path in ("zernmclauncher.jar", "zernmc-bootstrap.jar"): + continue + stat = file_path.stat() + + # Calculate hash + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + + files.append({ + "path": rel_path, + "size": stat.st_size, + "hash": f"sha256:{sha256_hash.hexdigest()}" + }) except Exception as e: logger.warning(f"Failed to generate launcher meta: {e}") return @@ -1905,6 +1912,37 @@ async def get_news(news_id: str): } +# ====================== WHITELIST MODS ====================== + +WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt" + + +@app.get("/whitelist/mods") +async def list_whitelist_mods(current_user: dict = Depends(get_current_user)): + """Return whitelist mods list as name :: size :: hash lines""" + if not WHITELIST_MODS_FILE.exists(): + return Response(content="", media_type="text/plain") + + content = WHITELIST_MODS_FILE.read_text(encoding="utf-8").strip() + if not content: + return Response(content="", media_type="text/plain") + + return Response(content=content, media_type="text/plain") + + +@app.get("/whitelist/mods/{mod_hash}/{filename:path}") +async def download_whitelist_mod(mod_hash: str, filename: str, request: Request, current_user: dict = Depends(get_current_user)): + """Download whitelist mod file by hash""" + if ".." in mod_hash or ".." in filename: + raise HTTPException(400, "Invalid path") + + mod_path = WHITELIST_DIR / mod_hash + if not mod_path.exists() or not mod_path.is_file(): + raise HTTPException(404, "Mod not found") + + return await send_file_async(mod_path, request, cache=True) + + # ====================== ПРОКСИ ЭНДПОИНТЫ ====================== # Эти эндпоинты позволяют клиентам с сетевыми проблемами # скачивать файлы через сервер Zern diff --git a/test_install_modal.py b/test_install_modal.py new file mode 100644 index 0000000..2feb01c --- /dev/null +++ b/test_install_modal.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +Playwright test for the launcher install modal. +Mocks the server and tests that the install modal loads packs correctly. +""" +import json, os, threading, time, socket, sys +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path +from urllib.parse import urlparse, parse_qs +from playwright.sync_api import sync_playwright + +UI_DIR = Path("/root/launcher/launcher/launcher/src/resources/ui") +PORT = 9877 + +MOCK_INSTANCES = [ + {"name": "ZernMC-Vanilla", "version": "1.21", "loaderType": "vanilla", + "isServerPack": True, "serverPackName": "ZernMC", "serverVersion": 1, + "filesCount": 0, "category": "zernmc"}, + {"name": "My Local World", "version": "1.20.1", "loaderType": "fabric", + "isServerPack": False, "serverPackName": None, "serverVersion": 0, + "filesCount": 42, "category": "local", + "loaderVersion": "0.15.11"}, +] + +MOCK_SERVER_PACKS = [ + {"name": "ZernMC", "version": 5, "minecraft_version": "1.21", "loader_type": "vanilla", + "files_count": 128, "description": "Main server pack", "displayName": "ZernMC"}, + {"name": "ZernMC-Modded", "version": 3, "minecraft_version": "1.20.1", "loader_type": "fabric", + "files_count": 256, "loader_version": "0.15.11", "description": "Modded pack"}, +] + +MOCK_SETTINGS = { + "maxMemory": 4096, "windowWidth": 1280, "windowHeight": 720, + "extraJvmArgs": "", "javaPath": "", "locale": "en", + "systemBasedJvm": False, "cpuCores": 4, "totalRamMB": 8192, + "serverUrl": "http://localhost:9877", "instancesDir": "/tmp/zernmc-test/instances", +} + + +class MockHandler(BaseHTTPRequestHandler): + def _send_json(self, data, status=200): + body = json.dumps(data).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self): + length = int(self.headers.get("Content-Length", 0)) + return json.loads(self.rfile.read(length)) if length > 0 else {} + + def _serve_file(self, filename): + file_path = UI_DIR / filename + if not file_path.exists() or not file_path.is_file(): + return False + content = file_path.read_bytes() + ext = file_path.suffix + ct_map = {".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8"} + self.send_response(200) + self.send_header("Content-Type", ct_map.get(ext, "application/octet-stream")) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + return True + + def do_GET(self): + path = self.path + if path in ("/", "/index.html"): + self._serve_file("index.html") + elif path == "/launcher.js": + self._serve_file("launcher.js") + elif path == "/style.css": + self._serve_file("style.css") + elif path == "/marked.min.js": + self._serve_file("marked.min.js") + elif "/api/auto-login" in path: + self._send_json({"success": True, "autoLogin": True, + "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}}) + elif "/api/account" in path: + self._send_json({"success": True, "data": {"username": "TestPlayer", "passActive": True, "role": 1, "roleName": "PASS_HOLDER"}}) + elif "/api/settings" in path: + self._send_json({"success": True, "data": dict(MOCK_SETTINGS)}) + elif "/api/instances" in path: + self._send_json({"success": True, "data": MOCK_INSTANCES}) + elif "/api/packs" in path: + self._send_json({"success": True, "data": MOCK_SERVER_PACKS}) + elif "/api/news" in path: + self._send_json({"success": True, "data": json.dumps({"news": []})}) + elif "/api/mc-versions" in path: + self._send_json({"success": True, "data": ["1.21", "1.20.1", "1.20", "1.19.4"]}) + elif "/api/loader-versions" in path: + self._send_json({"success": True, "data": ["0.15.11", "0.15.10"]}) + elif "/api/pack-info" in path: + self._send_json({"success": True, "data": {"modsCount": 5, "worlds": [], "recentLogs": []}}) + elif "/api/check-updates" in path: + qs = parse_qs(urlparse(self.path).query) + name = qs.get("name", [""])[0] + for inst in MOCK_INSTANCES: + if inst["name"] == name: + self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}}) + return + self._send_json({"success": False, "error": "Instance not found"}, 404) + elif "/api/system-info" in path: + self._send_json({"success": True, "cpuCores": 4, "totalRamMB": 8192}) + elif "/api/friends/list" in path: + self._send_json({"friends": []}) + elif "/api/friends/requests" in path: + self._send_json({"requests": []}) + elif "/api/playtime/stats" in path: + self._send_json({"total_minutes": 0, "total_hours": 0, "packs": []}) + else: + self._send_json({"success": False, "error": "Not found"}, 404) + + def do_POST(self): + path = self.path + body = self._read_body() + if "/api/login" in path: + self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}}) + elif "/api/register" in path: + self._send_json({"success": True, "data": {"username": body.get("username", "Player"), "passActive": False, "role": 0, "roleName": ""}}) + elif "/api/settings" in path: + self._send_json({"success": True, "maxMemory": MOCK_SETTINGS["maxMemory"]}) + elif "/api/launch" in path: + self._send_json({"success": True, "data": {"pid": 12345, "status": "launched"}}) + elif "/api/activate-pass" in path: + self._send_json({"success": True, "message": "Pass activated!"}) + elif "/api/logout" in path: + self._send_json({"success": True}) + elif "/api/update" in path: + name = body.get("name", "") + self._send_json({"success": True, "data": {"version": 2}}) + elif "/api/install" in path: + self._send_json({"success": True, "message": "Installation started"}) + elif "/api/install/progress" in path: + self._send_json({"success": True, "data": { + "label": "Complete", "inProgress": False, "percent": 100, + "stageName": "Done", "stageIndex": 0, "stageCount": 1 + }}) + else: + self._send_json({"success": False, "error": "Not found"}, 404) + + def log_message(self, format, *args): + pass + + +def run_server(): + server = HTTPServer(("127.0.0.1", PORT), MockHandler) + server.serve_forever() + + +def wait_for_server(host, port, timeout=10): + start = time.time() + while time.time() - start < timeout: + try: + s = socket.socket() + s.connect((host, port)) + s.close() + return True + except: + time.sleep(0.1) + return False + + +def main(): + svr = threading.Thread(target=run_server, daemon=True) + svr.start() + if not wait_for_server("127.0.0.1", PORT): + print("Failed to start mock server") + return 1 + print(f"Mock server running on http://127.0.0.1:{PORT}") + + passed = 0 + failed = 0 + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + context = browser.new_context(viewport={"width": 1280, "height": 720}) + page = context.new_page() + + console_errors = [] + page.on("console", lambda msg: console_errors.append(f"[{msg.type}] {msg.text}") if msg.type == "error" else None) + page.on("pageerror", lambda err: console_errors.append(f"[PAGE_ERROR] {err}")) + + try: + # ===== Navigate and wait for login ===== + page.goto(f"http://127.0.0.1:{PORT}/", wait_until="load", timeout=15000) + page.wait_for_timeout(3000) + + # Verify we're on main screen + main_screen = page.locator("#main-screen") + if not main_screen.is_visible(): + print("FAIL: Main screen not visible after auto-login") + return 1 + + print("--- Test 1: Open install modal ---") + add_pack_btn = page.locator("#add-pack-btn") + add_pack_btn.click() + page.wait_for_timeout(1000) + + install_modal = page.locator("#install-modal") + if install_modal.is_visible(): + print(" PASS: Install modal opened") + passed += 1 + else: + print(" FAIL: Install modal not visible") + failed += 1 + + # ===== Check pack select (zernmc-pack-select) ===== + print("--- Test 2: Pack select populated ---") + pack_select = page.locator("#zernmc-pack-select") + page.wait_for_timeout(500) + options_text = pack_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')") + print(f" Options: {options_text}") + if "ZernMC (5)" in options_text and "Select a pack" in options_text: + print(" PASS: Pack select has server packs") + passed += 1 + else: + print(" FAIL: Pack select not populated correctly") + page.screenshot(path="/tmp/install-modal-packs.png") + failed += 1 + + # ===== Check MC version select (may not exist in HTML) ===== + print("--- Test 3: MC version select handled gracefully ---") + mc_select = page.locator("#mc-version-select") + mc_exists = mc_select.count() + if mc_exists == 0: + print(" PASS: MC version select absent (custom tab disabled), no crash") + else: + options_text = mc_select.evaluate("sel => Array.from(sel.options).map(o => o.text).join('|')") + print(f" Options: {options_text}") + if "1.21" in options_text: + print(" PASS: MC version select populated") + else: + print(" FAIL: MC version select not populated") + page.screenshot(path="/tmp/install-modal-mc.png") + passed += 1 + + # ===== Check install button exists ===== + print("--- Test 4: Install button exists ---") + install_btn = page.locator("#install-zernmc-btn") + if install_btn.is_visible(): + print(" PASS: Install button visible") + passed += 1 + else: + print(" FAIL: Install button not visible") + failed += 1 + + # ===== Check instance name input ===== + print("--- Test 5: Instance name input exists ---") + name_input = page.locator("#zernmc-instance-name") + if name_input.is_visible(): + print(" PASS: Instance name input visible") + passed += 1 + else: + print(" FAIL: Instance name input not visible") + failed += 1 + + except Exception as e: + print(f" FAIL: {e}") + import traceback + traceback.print_exc() + failed += 1 + + if console_errors: + print(f"\n--- Console errors ({len(console_errors)}) ---") + for e in console_errors[-10:]: + print(f" {e}") + + browser.close() + + print(f"\n{'='*40}") + print(f"Results: {passed} passed, {failed} failed") + print(f"{'='*40}") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test_launcher_integration.py b/test_launcher_integration.py index ebeb1b7..dee5fb0 100644 --- a/test_launcher_integration.py +++ b/test_launcher_integration.py @@ -128,6 +128,15 @@ class MockHandler(BaseHTTPRequestHandler): self._send_json({"requests": []}) elif "/api/playtime/stats" in path: self._send_json({"total_minutes": 120, "total_hours": 2.0, "packs": [{"pack_name": "TestPack", "minutes": 120}]}) + elif "/api/check-updates" in path: + from urllib.parse import urlparse, parse_qs + qs = parse_qs(urlparse(self.path).query) + name = qs.get("name", [""])[0] + for inst in MOCK_INSTANCES: + if inst["name"] == name: + self._send_json({"success": True, "data": {"hasUpdate": False, "serverVersion": 1, "localVersion": 1}}) + return + self._send_json({"success": False, "error": "Instance not found"}, 404) else: self._send_json({"success": False, "error": "Not found"}, 404) @@ -166,6 +175,9 @@ class MockHandler(BaseHTTPRequestHandler): self._send_json({"status": "ok"}) elif "/api/playtime/sync" in path: self._send_json({"status": "ok"}) + elif "/api/update" in path: + name = body.get("name", "") + self._send_json({"success": True, "data": {"version": 2}}) else: self._send_json({"success": False, "error": "Not found"}, 404)