DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню
This commit is contained in:
@@ -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) {
|
||||
|
||||
+31
@@ -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<String, String> 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) {
|
||||
|
||||
+90
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -1365,6 +1405,233 @@ public class JFXLauncher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCheckUpdates(HttpExchange exchange) {
|
||||
try {
|
||||
Map<String, String> 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<String, String> 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<Map<String, Object>> 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<String, Object> 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<String, String> 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<Path> 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<String, String> 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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-nav-left">
|
||||
<div class="sidebar-nav-primary">
|
||||
<button class="nav-btn active" data-view="packs" title="Packs">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
|
||||
<span data-i18n="nav.packs">Packs</span>
|
||||
@@ -88,11 +88,15 @@
|
||||
<span data-i18n="nav.news">News</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-nav-right">
|
||||
<div class="sidebar-nav-secondary" id="nav-secondary">
|
||||
<button class="nav-btn" data-view="friends" title="Friends">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<span data-i18n="nav.friends">Friends</span>
|
||||
</button>
|
||||
<button class="nav-btn hidden" data-view="admin" title="Admin" id="admin-nav-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
|
||||
<span data-i18n="nav.admin">Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -185,6 +189,12 @@
|
||||
<div id="pack-gallery" class="pack-gallery">
|
||||
</div>
|
||||
</div>
|
||||
<div id="whitelist-mods-section" class="whitelist-mods-section hidden">
|
||||
<div class="section-header"><span data-i18n="whitelist.title">Additional Mods</span></div>
|
||||
<div id="whitelist-mods-list" class="whitelist-mods-list">
|
||||
<div class="whitelist-loading" data-i18n="whitelist.loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,6 +243,111 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin View -->
|
||||
<div id="view-admin" class="view">
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
|
||||
</div>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button>
|
||||
<button class="admin-tab" data-atab="mods"><span data-i18n="admin.mods">Mods</span></button>
|
||||
<button class="admin-tab" data-atab="users"><span data-i18n="admin.users">Users</span></button>
|
||||
<button class="admin-tab" data-atab="passes"><span data-i18n="admin.passes">Passes</span></button>
|
||||
<button class="admin-tab" data-atab="news"><span data-i18n="admin.news">News</span></button>
|
||||
</div>
|
||||
<div class="admin-content">
|
||||
<!-- Clients -->
|
||||
<div id="atab-clients" class="admin-tab-content active">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.clientsOnline">Online Clients</span></div>
|
||||
<div id="admin-clients-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Whitelist Mods -->
|
||||
<div id="atab-mods" class="admin-tab-content">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.modsUpload">Upload Mod</span></div>
|
||||
<div class="admin-row">
|
||||
<input type="file" id="admin-mod-file" accept=".jar" class="admin-file-input">
|
||||
<button class="btn-primary btn-sm" onclick="app.adminUploadMod()"><span data-i18n="admin.modsUploadBtn">Upload</span></button>
|
||||
</div>
|
||||
<div id="admin-mod-status" class="admin-status hidden"></div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.modsList">Whitelist Mods</span></div>
|
||||
<div id="admin-mods-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Users -->
|
||||
<div id="atab-users" class="admin-tab-content">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.usersSearch">User Management</span></div>
|
||||
<div class="admin-row">
|
||||
<input type="text" id="admin-user-search" class="admin-input" placeholder="Username..." data-i18n-placeholder="admin.usersSearchPlaceholder">
|
||||
<button class="btn-primary btn-sm" onclick="app.adminSearchUser()"><span data-i18n="admin.search">Search</span></button>
|
||||
</div>
|
||||
<div id="admin-user-results" class="admin-list"></div>
|
||||
</div>
|
||||
<div id="admin-user-detail" class="admin-card hidden">
|
||||
<div class="admin-card-header" id="admin-user-detail-name"></div>
|
||||
<div class="admin-form">
|
||||
<div class="admin-row">
|
||||
<span data-i18n="admin.role">Role:</span>
|
||||
<select id="admin-user-role-select" class="admin-select">
|
||||
<option value="0">USER</option>
|
||||
<option value="1">PASS_HOLDER</option>
|
||||
<option value="2">MODERATOR</option>
|
||||
<option value="3">ELDER</option>
|
||||
<option value="4">CREATOR</option>
|
||||
</select>
|
||||
<button class="btn-primary btn-sm" onclick="app.adminSetRole()"><span data-i18n="admin.save">Save</span></button>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<button class="btn-secondary btn-sm btn-danger" onclick="app.adminResetPassword()"><span data-i18n="admin.resetPass">Reset Password</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Passes -->
|
||||
<div id="atab-passes" class="admin-tab-content">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div>
|
||||
<div id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- News -->
|
||||
<div id="atab-news" class="admin-tab-content">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.newsCreate">Create News</span></div>
|
||||
<div class="admin-form">
|
||||
<div class="admin-row">
|
||||
<input type="text" id="admin-news-title" class="admin-input" placeholder="Title" data-i18n-placeholder="admin.newsTitlePlaceholder">
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<select id="admin-news-type" class="admin-select">
|
||||
<option value="Update">Update</option>
|
||||
<option value="Announcement">Announcement</option>
|
||||
<option value="Event">Event</option>
|
||||
</select>
|
||||
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea>
|
||||
</div>
|
||||
<div class="admin-row">
|
||||
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
|
||||
</div>
|
||||
<div id="admin-news-status" class="admin-status hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header"><span data-i18n="admin.newsManage">Manage News</span></div>
|
||||
<div id="admin-news-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings View -->
|
||||
<div id="view-settings" class="view">
|
||||
<div class="view-header">
|
||||
@@ -345,8 +460,8 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<input type="text" id="add-friend-input" placeholder=" " data-i18n-placeholder="friends.addPlaceholder">
|
||||
<label data-i18n="friends.addLabel">Username</label>
|
||||
<input type="text" id="add-friend-input" placeholder="Enter username..." data-i18n-placeholder="friends.addPlaceholder">
|
||||
</div>
|
||||
<button id="add-friend-submit" class="btn-primary" onclick="app.submitAddFriend()"><span data-i18n="friends.add">Add Friend</span></button>
|
||||
<p id="add-friend-error" class="error-msg hidden"></p>
|
||||
@@ -391,10 +506,6 @@
|
||||
<option value="">Loading...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label data-i18n="install.localName.label">Local Name</label>
|
||||
<input type="text" id="zernmc-instance-name" placeholder="my-cool-pack">
|
||||
</div>
|
||||
<button id="install-zernmc-btn" class="btn-primary"><span data-i18n="install.downloadBtn">Download & Install</span></button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="pack-entry-icon ${isZern ? 'server' : 'local'}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${isZern
|
||||
? '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>'
|
||||
: '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>'
|
||||
}</svg>
|
||||
${hasUpd ? '<span class="update-badge"></span>' : ''}
|
||||
</div>
|
||||
<div class="pack-entry-info">
|
||||
<div class="pack-entry-name">${this.esc(inst.name)}</div>
|
||||
<div class="pack-entry-name">${this.esc(inst.name)}${hasUpd ? ' <span class="update-label">' + t('pack.update') + '</span>' : ''}</div>
|
||||
<div class="pack-entry-meta">${inst.version || '?'}${inst.loaderType && inst.loaderType !== 'vanilla' ? ' \u00b7 ' + inst.loaderType : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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 = '<div class="whitelist-loading">' + t('whitelist.loading') + '</div>';
|
||||
const r = await this.req('/whitelist/mods');
|
||||
if (!r.success || !r.data) {
|
||||
listEl.innerHTML = '<div class="whitelist-loading">' + (r.error || t('whitelist.error')) + '</div>';
|
||||
return;
|
||||
}
|
||||
if (r.data.length === 0) {
|
||||
listEl.innerHTML = '<div class="whitelist-loading">' + t('whitelist.empty') + '</div>';
|
||||
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 += '<label class="whitelist-mod-item">'
|
||||
+ '<input type="checkbox" class="whitelist-mod-checkbox" data-hash="' + mod.hash + '" data-name="' + mod.name + '">'
|
||||
+ '<div class="whitelist-mod-info">'
|
||||
+ '<div class="whitelist-mod-name">' + this.esc(mod.name) + '</div>'
|
||||
+ '<div class="whitelist-mod-size">' + sizeStr + '</div>'
|
||||
+ '</div>'
|
||||
+ '</label>';
|
||||
}, this);
|
||||
html += '<button class="whitelist-install-btn" onclick="app.installSelectedWhitelistMods(\'' + instanceName + '\')">'
|
||||
+ '<span data-i18n="whitelist.install">Install Selected</span></button>';
|
||||
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 += '<div class="whitelist-mod-status">' + t('whitelist.installed') + '</div>';
|
||||
} else {
|
||||
this.toast(r.error || t('whitelist.installError'), 'error');
|
||||
}
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<span data-i18n="whitelist.install">Install Selected</span>';
|
||||
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 = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||
var r = await this.req('/admin/clients');
|
||||
if (!r.success) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + ': ' + (r.error || '') + '</div>';
|
||||
return;
|
||||
}
|
||||
var clients = r.clients || r.data || [];
|
||||
if (!clients.length) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
clients.forEach(function(c) {
|
||||
html += '<div class="admin-item">'
|
||||
+ '<span class="admin-item-name">' + app.esc(c.username || c.name || '?') + '</span>'
|
||||
+ '<span class="admin-item-meta">IP: ' + app.esc(c.ip || '-') + '</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
setTimeout(function() { app.adminLoadClients(); }, 5000);
|
||||
}
|
||||
|
||||
// --- Mods ---
|
||||
async adminLoadMods() {
|
||||
var el = document.getElementById('admin-mods-list');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||
var r = await this.req('/admin/whitelist/mods');
|
||||
if (!r.success) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
|
||||
return;
|
||||
}
|
||||
var mods = r.mods || r.data || [];
|
||||
if (!mods.length) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.modsEmpty') + '</div>';
|
||||
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 += '<div class="admin-item">'
|
||||
+ '<span class="admin-item-name">' + app.esc(m.name) + '</span>'
|
||||
+ '<span class="admin-item-meta">' + sizeStr + '</span>'
|
||||
+ '<div class="admin-item-actions">'
|
||||
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeleteMod(\'' + m.hash + '\')">' + t('admin.delete') + '</button>'
|
||||
+ '</div></div>';
|
||||
});
|
||||
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 = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||
var r = await this.req('/admin/users/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ search: query })
|
||||
});
|
||||
if (!r.success) {
|
||||
results.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
|
||||
return;
|
||||
}
|
||||
var users = r.users || r.data || [];
|
||||
if (!users.length) {
|
||||
results.innerHTML = '<div class="admin-empty">' + t('admin.noUser') + '</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
users.forEach(function(u) {
|
||||
html += '<div class="admin-item" style="cursor:pointer" onclick="app.adminSelectUser(' + u.id + ',\'' + app.esc(u.username) + '\',' + (u.role || 0) + ')">'
|
||||
+ '<span class="admin-item-name">' + app.esc(u.username) + '</span>'
|
||||
+ '<span class="admin-item-meta">' + (u.role_name || 'Role ' + (u.role || 0)) + '</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
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 = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||
var r = await this.req('/admin/passes');
|
||||
if (!r.success) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
|
||||
return;
|
||||
}
|
||||
var passes = r.passes || r.data || [];
|
||||
if (!passes.length) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.passesEmpty') + '</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
passes.forEach(function(p) {
|
||||
html += '<div class="admin-item">'
|
||||
+ '<span class="admin-item-name">' + app.esc(p.code || p.name || '?') + '</span>'
|
||||
+ '<span class="admin-item-meta">' + (p.username ? app.esc(p.username) : '') + '</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// --- News ---
|
||||
async adminLoadNewsList() {
|
||||
var el = document.getElementById('admin-news-list');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
|
||||
var r = await this.req('/news');
|
||||
if (!r.success) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.error') + '</div>';
|
||||
return;
|
||||
}
|
||||
var newsList = [];
|
||||
try { newsList = (typeof r.data === 'string' ? JSON.parse(r.data) : r.data).news || []; } catch(e) {}
|
||||
if (!newsList.length) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.newsEmpty') + '</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
newsList.forEach(function(item, idx) {
|
||||
html += '<div class="admin-news-item">'
|
||||
+ '<div class="admin-news-info">'
|
||||
+ '<div class="admin-news-title">' + app.esc(item.title) + '</div>'
|
||||
+ '<div class="admin-news-meta">' + app.esc(item.type) + ' \u00b7 ' + app.esc(item.version || '') + '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="admin-item-actions">'
|
||||
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeleteNews(\'' + idx + '\')">' + t('admin.delete') + '</button>'
|
||||
+ '</div></div>';
|
||||
});
|
||||
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 = '<option>' + t('select.loading') + '</option>';
|
||||
document.getElementById('mc-version-select').innerHTML = '<option>' + t('select.loading') + '</option>';
|
||||
|
||||
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 = '<option value="">' + t('select.selectPack') + '</option>';
|
||||
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 = '<option value="">' + t('select.passRequired') + '</option>';
|
||||
const mcSel = document.getElementById('mc-version-select');
|
||||
if (zernmcSel) {
|
||||
zernmcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
|
||||
}
|
||||
if (mcSel) {
|
||||
mcSel.innerHTML = '<option>' + t('select.loading') + '</option>';
|
||||
}
|
||||
|
||||
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 = '<option value="">' + t('select.selectPack') + '</option>';
|
||||
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 = '<option value="">' + t('select.passRequired') + '</option>';
|
||||
zernmcSel.disabled = true;
|
||||
}
|
||||
} else if (zernmcSel) {
|
||||
zernmcSel.innerHTML = '<option value="">' + t('select.noPacks') + '</option>';
|
||||
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 = '<option value="">' + t('select.failedLoad') + '</option>';
|
||||
zernmcSel.disabled = true;
|
||||
}
|
||||
} else {
|
||||
zernmcSel.innerHTML = '<option value="">' + t('select.noPacks') + '</option>';
|
||||
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 = '<option value="">' + t('select.selectVersion') + '</option>';
|
||||
mc.data.forEach(v => {
|
||||
const o = document.createElement('option');
|
||||
o.value = v; o.textContent = v;
|
||||
mcSel.appendChild(o);
|
||||
});
|
||||
} else {
|
||||
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
|
||||
if (mcSel) {
|
||||
try {
|
||||
const mc = await this.req('/mc-versions');
|
||||
if (mc.success && Array.isArray(mc.data)) {
|
||||
mcSel.innerHTML = '<option value="">' + t('select.selectVersion') + '</option>';
|
||||
mc.data.forEach(v => {
|
||||
const o = document.createElement('option');
|
||||
o.value = v; o.textContent = v;
|
||||
mcSel.appendChild(o);
|
||||
});
|
||||
} else {
|
||||
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
|
||||
}
|
||||
} catch (e) {
|
||||
mcSel.innerHTML = '<option value="">' + t('select.failedLoad') + '</option>';
|
||||
}
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+331
-2
@@ -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)):
|
||||
"""Информация о текущем пользователе с правами"""
|
||||
|
||||
+61
-23
@@ -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()
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
rel_path = str(file_path.relative_to(BUILDS_DIR))
|
||||
|
||||
# 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)
|
||||
# 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()
|
||||
|
||||
files.append({
|
||||
"path": rel_path,
|
||||
"size": stat.st_size,
|
||||
"hash": f"sha256:{sha256_hash.hexdigest()}"
|
||||
})
|
||||
# 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
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user