JCEF migration: add jcefmaven dep, make JavaFX optional, create LogBridge, wire JCEFLauncher entry point

This commit is contained in:
SashegDev
2026-07-02 11:49:09 +00:00
parent 0d61ad1107
commit 8eabe232ff
17 changed files with 3017 additions and 133 deletions
@@ -37,6 +37,7 @@ public class Bootstrap {
private static Path javafxPath; private static Path javafxPath;
private static boolean isCliMode; private static boolean isCliMode;
private static boolean isJfxMode; private static boolean isJfxMode;
private static boolean isJcefMode;
private static BootstrapUI ui; private static BootstrapUI ui;
private static Path getLauncherJar() { private static Path getLauncherJar() {
@@ -54,13 +55,29 @@ public class Bootstrap {
log("=== ZernMC Launcher ==="); log("=== ZernMC Launcher ===");
List<String> argList = new ArrayList<>(Arrays.asList(args)); List<String> argList = new ArrayList<>(Arrays.asList(args));
isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx")); isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx") && !argList.contains("--jcef"));
isJfxMode = !isCliMode; // Auto-detect UI backend: prefer JCEF, fall back to JavaFX
boolean javafxAvailable = false;
if (Files.exists(javafxPath)) {
try (var list = Files.list(javafxPath)) {
javafxAvailable = list.findAny().isPresent();
} catch (Exception ignored) {}
}
if (argList.contains("--jfx")) {
isJfxMode = true;
isJcefMode = false;
} else if (argList.contains("--jcef")) {
isJfxMode = false;
isJcefMode = true;
} else {
isJfxMode = javafxAvailable;
isJcefMode = !javafxAvailable;
}
if (isCliMode && !argList.contains("--cli")) { if (isCliMode && !argList.contains("--cli")) {
argList.add("--cli"); argList.add("--cli");
} }
log("Mode: " + (isCliMode ? "CLI" : "JFX")); log("Mode: " + (isCliMode ? "CLI" : (isJcefMode ? "JCEF" : "JFX")));
if (!isCliMode && !GraphicsEnvironment.isHeadless()) { if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
ui = new BootstrapUI(); ui = new BootstrapUI();
@@ -146,16 +163,26 @@ public class Bootstrap {
cmd.add("-Dsun.stderr.encoding=UTF-8"); cmd.add("-Dsun.stderr.encoding=UTF-8");
cmd.add("-Dlauncher.server=" + BASE_URL); cmd.add("-Dlauncher.server=" + BASE_URL);
if (Files.exists(javafxPath)) { if (isJfxMode && Files.exists(javafxPath)) {
cmd.add("--module-path"); cmd.add("--module-path");
cmd.add(javafxPath.toAbsolutePath().toString()); cmd.add(javafxPath.toAbsolutePath().toString());
cmd.add("--add-modules"); cmd.add("--add-modules");
cmd.add("javafx.controls,javafx.web"); cmd.add("javafx.controls,javafx.web");
} }
if (isJcefMode) {
// JCEF requires --add-opens for macOS
cmd.add("--add-opens");
cmd.add("java.desktop/sun.awt=ALL-UNNAMED");
cmd.add("--add-opens");
cmd.add("java.desktop/sun.lwawt=ALL-UNNAMED");
cmd.add("--add-opens");
cmd.add("java.desktop/sun.lwawt.macosx=ALL-UNNAMED");
}
cmd.add("-jar"); cmd.add("-jar");
cmd.add(getLauncherJar().toAbsolutePath().toString()); cmd.add(getLauncherJar().toAbsolutePath().toString());
cmd.add("--jfx"); cmd.add(isJcefMode ? "--jcef" : "--jfx");
ProcessBuilder pb = new ProcessBuilder(cmd); ProcessBuilder pb = new ProcessBuilder(cmd);
pb.directory(baseDir.toFile()); pb.directory(baseDir.toFile());
+78 -35
View File
@@ -57,36 +57,10 @@
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>
</dependency> </dependency>
<!-- JavaFX - Windows --> <!-- JCEF (Chromium Embedded Framework) - auto-downloads natives -->
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>me.friwi</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>jcefmaven</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency> </dependency>
<!-- Test --> <!-- Test -->
@@ -98,8 +72,77 @@
</dependency> </dependency>
</dependencies> </dependencies>
<!-- JavaFX profile (optional, activate with -DuseJavaFX=true) -->
<profiles>
<profile>
<id>javafx</id>
<activation>
<property>
<name>useJavaFX</name>
<value>true</value>
</property>
</activation>
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<excludes combine.self="override">
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
<build> <build>
<plugins> <plugins>
<!-- Exclude JFXLauncher from default build (requires JavaFX profile) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<excludes>
<exclude>**/ui/jfx/JFXLauncher.java</exclude>
</excludes>
</configuration>
</plugin>
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId> <artifactId>maven-shade-plugin</artifactId>
@@ -223,29 +266,29 @@
</fileset> </fileset>
</copy> </copy>
<!-- Копируем JavaFX модули в lib/javafx --> <!-- Копируем JavaFX модули в lib/javafx (если есть, иначе JCEF режим) -->
<mkdir dir="../../server/builds/lib/javafx"/> <mkdir dir="../../server/builds/lib/javafx"/>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
</copy> </copy>
<copy todir="../../server/builds/lib/javafx" overwrite="true"> <copy todir="../../server/builds/lib/javafx" overwrite="true" failonerror="false">
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21"> <fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
<include name="*win.jar"/> <include name="*win.jar"/>
</fileset> </fileset>
@@ -4,7 +4,7 @@ import me.sashegdev.zernmc.launcher.api.LauncherAPI;
import me.sashegdev.zernmc.launcher.auth.AuthManager; import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.menu.*; import me.sashegdev.zernmc.launcher.menu.*;
import me.sashegdev.zernmc.launcher.ui.ArrowMenu; import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; import me.sashegdev.zernmc.launcher.ui.jcef.JCEFLauncher;
import me.sashegdev.zernmc.launcher.utils.*; import me.sashegdev.zernmc.launcher.utils.*;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@@ -35,8 +35,14 @@ public class Main {
List<String> argList = List.of(args); List<String> argList = List.of(args);
boolean jfxMode = argList.contains("--jfx"); boolean jfxMode = argList.contains("--jfx");
boolean jcefMode = argList.contains("--jcef");
boolean cliMode = argList.contains("--cli"); boolean cliMode = argList.contains("--cli");
if (jcefMode) {
launchJCEF();
return;
}
if (jfxMode) { if (jfxMode) {
launchJFX(); launchJFX();
return; return;
@@ -48,14 +54,27 @@ public class Main {
startCLI(); startCLI();
} }
private static void launchJCEF() {
try {
JCEFLauncher.main(new String[]{});
} catch (Exception e) {
System.err.println(ZAnsi.brightRed("Error starting JCEF: " + e.getMessage()));
e.printStackTrace();
System.exit(1);
}
}
private static void launchJFX() { private static void launchJFX() {
try { try {
System.setProperty("javafx.runtime.version", "21"); System.setProperty("javafx.runtime.version", "21");
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
JFXLauncher.main(new String[]{}); java.lang.reflect.Method mainMethod = jfxClass.getMethod("main", String[].class);
mainMethod.invoke(null, new Object[]{new String[]{}});
} catch (Exception e) { } catch (Exception e) {
System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage())); System.err.println(ZAnsi.brightRed("Error starting JFX: " + e.getMessage()));
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) { Throwable cause = e.getCause();
String msg = cause != null ? cause.getMessage() : e.getMessage();
if (msg != null && msg.contains("QuantumRenderer")) {
System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing.")); System.err.println(ZAnsi.yellow("JavaFX is not available. Native libraries may be missing."));
System.err.println(ZAnsi.yellow("Try CLI mode: --cli")); System.err.println(ZAnsi.yellow("Try CLI mode: --cli"));
} }
@@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager; import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; import me.sashegdev.zernmc.launcher.utils.LogBridge;
import me.sashegdev.zernmc.launcher.utils.Config; import me.sashegdev.zernmc.launcher.utils.Config;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger; import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
@@ -98,14 +98,14 @@ public class LaunchService {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line; String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
JFXLauncher.appendGameLog(line); LogBridge.appendGameLog(line);
try { try {
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8)); logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
logFileOut.flush(); logFileOut.flush();
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
} catch (Exception e) { } catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]"); LogBridge.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
} finally { } finally {
try { logFileOut.close(); } catch (Exception ignored) {} try { logFileOut.close(); } catch (Exception ignored) {}
} }
@@ -115,7 +115,7 @@ public class LaunchService {
process.onExit().thenRun(() -> { process.onExit().thenRun(() -> {
runningProcesses.remove(pid); runningProcesses.remove(pid);
JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]"); LogBridge.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]");
}); });
ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING");
@@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller; import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder; import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions; import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher; import me.sashegdev.zernmc.launcher.utils.LogBridge;
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils; import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
import me.sashegdev.zernmc.launcher.utils.LauncherLogger; import me.sashegdev.zernmc.launcher.utils.LauncherLogger;
import me.sashegdev.zernmc.launcher.utils.ZAnsi; import me.sashegdev.zernmc.launcher.utils.ZAnsi;
@@ -130,10 +130,10 @@ public class MinecraftLib {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog(line); LogBridge.appendGameLog(line);
} }
} catch (Exception e) { } catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]"); LogBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]");
} }
}); });
outThread.setDaemon(true); outThread.setDaemon(true);
@@ -144,10 +144,10 @@ public class MinecraftLib {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line; String line;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
JFXLauncher.appendGameLog("[ERR] " + line); LogBridge.appendGameLog("[ERR] " + line);
} }
} catch (Exception e) { } catch (Exception e) {
JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]"); LogBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
} }
}); });
errThread.setDaemon(true); errThread.setDaemon(true);
@@ -504,6 +504,7 @@ public class JFXLauncher extends Application {
server.createContext("/api/playtime/stats", this::handlePlaytimeStats); server.createContext("/api/playtime/stats", this::handlePlaytimeStats);
server.createContext("/api/whitelist/mods", this::handleWhitelistMods); server.createContext("/api/whitelist/mods", this::handleWhitelistMods);
server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod); server.createContext("/api/whitelist/mods/install", this::handleWhitelistInstallMod);
server.createContext("/api/token", this::handleToken);
server.createContext("/api/admin", this::handleAdmin); server.createContext("/api/admin", this::handleAdmin);
server.createContext("/assets/", this::handleStatic); server.createContext("/assets/", this::handleStatic);
@@ -611,6 +612,23 @@ public class JFXLauncher extends Application {
} }
} }
private void handleToken(HttpExchange exchange) {
try {
if (!api.isLoggedIn()) {
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
return;
}
String token = AuthManager.getAccessToken();
if (token == null || token.isEmpty() || "0".equals(token)) {
sendJson(exchange, Map.of("success", false, "error", "No token"));
return;
}
sendJson(exchange, Map.of("success", true, "token", token));
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleAutoLogin(HttpExchange exchange) { private void handleAutoLogin(HttpExchange exchange) {
try { try {
boolean loggedIn = AuthManager.isLoggedIn(); boolean loggedIn = AuthManager.isLoggedIn();
@@ -0,0 +1,96 @@
package me.sashegdev.zernmc.launcher.utils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.CopyOnWriteArrayList;
public class LogBridge {
private static final StringBuilder launcherLogBuffer = new StringBuilder();
private static final StringBuilder gameLogBuffer = new StringBuilder();
private static Path gameLogFile;
private static final CopyOnWriteArrayList<LogConsumer> logConsumers = new CopyOnWriteArrayList<>();
private static final CopyOnWriteArrayList<LogConsumer> gameLogConsumers = new CopyOnWriteArrayList<>();
public interface LogConsumer {
void onLog(String line);
}
public static void addLogConsumer(LogConsumer consumer) {
logConsumers.add(consumer);
}
public static void removeLogConsumer(LogConsumer consumer) {
logConsumers.remove(consumer);
}
public static void addGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.add(consumer);
}
public static void removeGameLogConsumer(LogConsumer consumer) {
gameLogConsumers.remove(consumer);
}
public static void appendLauncherLog(String msg) {
synchronized (launcherLogBuffer) {
launcherLogBuffer.append(msg).append("\n");
}
LauncherLogger.info("[EXT] " + msg);
for (LogConsumer consumer : logConsumers) {
try { consumer.onLog(msg); } catch (Exception ignored) {}
}
}
public static void appendGameLog(String msg) {
synchronized (gameLogBuffer) {
gameLogBuffer.append(msg).append("\n");
if (gameLogFile != null) {
try {
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
Files.writeString(gameLogFile, "[" + timestamp + "] " + msg + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (Exception ignored) {}
}
}
LauncherLogger.info("[GAME] " + msg);
for (LogConsumer consumer : gameLogConsumers) {
try { consumer.onLog(msg); } catch (Exception ignored) {}
}
}
public static void initGameLog(Path instanceDir) {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
try {
Path logsDir = instanceDir.resolve("logs");
Files.createDirectories(logsDir);
gameLogFile = logsDir.resolve("game.log");
Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n",
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (Exception ignored) {}
}
public static void clearGameLog() {
synchronized (gameLogBuffer) {
gameLogBuffer.setLength(0);
}
}
public static String getGameLogs() {
synchronized (gameLogBuffer) {
return gameLogBuffer.toString();
}
}
public static String getLauncherLogs() {
synchronized (launcherLogBuffer) {
return launcherLogBuffer.toString();
}
}
}
+38 -6
View File
@@ -32,11 +32,15 @@
<form id="login-form" class="login-form"> <form id="login-form" class="login-form">
<div class="field"> <div class="field">
<input type="text" id="username" placeholder="Username" data-i18n-placeholder="login.username" autocomplete="username" required> <input type="text" id="username" placeholder=" " data-i18n-placeholder="login.username" autocomplete="username" required>
<label for="username" data-i18n="login.username">Username</label> <label for="username" data-i18n="login.username">Username</label>
</div> </div>
<div class="field" id="confirm-password-field" style="display:none">
<input type="password" id="confirm-password" placeholder=" " autocomplete="new-password">
<label>Confirm Password</label>
</div>
<div class="field"> <div class="field">
<input type="password" id="password" placeholder="Password" data-i18n-placeholder="login.password" autocomplete="current-password" required> <input type="password" id="password" placeholder=" " data-i18n-placeholder="login.password" autocomplete="current-password" required>
<label for="password" data-i18n="login.password">Password</label> <label for="password" data-i18n="login.password">Password</label>
</div> </div>
<p id="login-error" class="error-msg hidden"></p> <p id="login-error" class="error-msg hidden"></p>
@@ -44,7 +48,9 @@
<span class="btn-label" data-i18n="login.title">Sign In</span> <span class="btn-label" data-i18n="login.title">Sign In</span>
<div class="spinner hidden"></div> <div class="spinner hidden"></div>
</button> </button>
<p class="login-hint" data-i18n="login.hint">New account will be created automatically on first login</p> <div class="login-extra">
<a id="login-toggle-link" onclick="app.toggleLoginMode()" data-i18n="login.register">No account? Register</a>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -59,7 +65,7 @@
<div id="main-screen" class="screen hidden"> <div id="main-screen" class="screen hidden">
<div class="shell"> <div class="shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-top"> <div class="sidebar-header">
<div class="sidebar-brand"> <div class="sidebar-brand">
<svg width="32" height="32" viewBox="0 0 56 56" fill="none"> <svg width="32" height="32" viewBox="0 0 56 56" fill="none">
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/> <rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
@@ -99,7 +105,9 @@
</button> </button>
</div> </div>
</nav> </nav>
</div>
<div class="sidebar-scroll">
<div class="sidebar-section"> <div class="sidebar-section">
<div class="section-header"> <div class="section-header">
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span> <span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
@@ -248,6 +256,12 @@
<div class="view-header"> <div class="view-header">
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2> <h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
</div> </div>
<div class="admin-dashboard" id="admin-dashboard">
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-total-users">-</div><div class="admin-stat-label">Total Users</div></div>
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-active-passes">-</div><div class="admin-stat-label">Active Passes</div></div>
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-online-now">-</div><div class="admin-stat-label">Online Now</div></div>
<div class="admin-stat-card"><div class="admin-stat-value" id="ad-new-users">-</div><div class="admin-stat-label">New (7d)</div></div>
</div>
<div class="admin-tabs"> <div class="admin-tabs">
<button class="admin-tab active" data-atab="clients"><span data-i18n="admin.clients">Clients</span></button> <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="mods"><span data-i18n="admin.mods">Mods</span></button>
@@ -310,6 +324,17 @@
</div> </div>
<!-- Passes --> <!-- Passes -->
<div id="atab-passes" class="admin-tab-content"> <div id="atab-passes" class="admin-tab-content">
<div class="admin-card">
<div class="admin-card-header">Create Pass Code</div>
<div class="pass-create-row">
<span style="font-size:12px;color:var(--text-muted)">Expires (days):</span>
<input type="number" id="pass-create-days" value="30" min="1" max="365">
<span style="font-size:12px;color:var(--text-muted)">Max uses:</span>
<input type="number" id="pass-create-uses" value="1" min="1" max="10">
<button class="btn-primary btn-sm" onclick="app.adminCreatePass()">Generate</button>
</div>
<div id="admin-pass-create-result" class="admin-status hidden"></div>
</div>
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"><span data-i18n="admin.passesList">Pass Management</span></div> <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 id="admin-passes-list" class="admin-list"><div class="admin-loading">Loading...</div></div>
@@ -331,8 +356,15 @@
</select> </select>
<input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px"> <input type="text" id="admin-news-version" class="admin-input" placeholder="Version" style="max-width:120px">
</div> </div>
<div class="admin-row"> <div class="md-editor-row">
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder"></textarea> <div class="md-editor-col">
<span style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Markdown</span>
<textarea id="admin-news-body" class="admin-textarea" placeholder="Body (Markdown)" data-i18n-placeholder="admin.newsBodyPlaceholder" oninput="app.updateNewsPreview()"></textarea>
</div>
<div class="md-editor-col">
<span style="font-size:11px;color:var(--text-muted);margin-bottom:4px">Preview</span>
<div id="admin-news-preview" class="md-preview">Preview will appear here...</div>
</div>
</div> </div>
<div class="admin-row"> <div class="admin-row">
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button> <button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
+367 -29
View File
@@ -5,6 +5,7 @@ const LOCALES = {
'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings', 'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings',
'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password', 'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password',
'login.hint': 'New account will be created automatically on first login', 'login.hint': 'New account will be created automatically on first login',
'login.register': 'No account? Register',
'login.signingIn': 'Signing in...', 'login.signingIn': 'Signing in...',
'loading.text': 'Loading...', 'loading.text': 'Loading...',
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs', 'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
@@ -171,6 +172,7 @@ const LOCALES = {
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки', 'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль', 'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль',
'login.hint': 'Новый аккаунт создаётся автоматически при первом входе', 'login.hint': 'Новый аккаунт создаётся автоматически при первом входе',
'login.register': 'Нет аккаунта? Зарегистрироваться',
'login.signingIn': 'Вход...', 'login.signingIn': 'Вход...',
'loading.text': 'Загрузка...', 'loading.text': 'Загрузка...',
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки', 'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
@@ -374,6 +376,11 @@ class ZernMCLauncher {
this._playtimeAccumulated = 0; this._playtimeAccumulated = 0;
this._playtimeStartTime = null; this._playtimeStartTime = null;
this._currentPlaytimePack = null; this._currentPlaytimePack = null;
this._ws = null;
this._wsReconnectTimer = null;
this._wsFallbackPolling = false;
this._wsStatusTimer = null;
this._adminClientsTimer = null;
this.init(); this.init();
} }
@@ -383,6 +390,142 @@ class ZernMCLauncher {
await this.checkAuth(); await this.checkAuth();
} }
// ==================== WEBSOCKET ====================
initWebSocket() {
if (typeof WebSocket === 'undefined') {
console.warn('[WS] WebSocket not supported, using polling');
this._startWsFallback();
return;
}
if (this._ws && (this._ws.readyState === WebSocket.OPEN || this._ws.readyState === WebSocket.CONNECTING)) return;
var self = this;
var doConnect = function(token) {
if (!token) { self._startWsFallback(); return; }
var serverUrl = 'http://87.120.187.36:1582';
var el = document.getElementById('server-url');
if (el && el.textContent && el.textContent !== '...') serverUrl = el.textContent;
var wsUrl = serverUrl.replace(/^http/, 'ws') + '/ws?token=' + encodeURIComponent(token);
self._connectWs(wsUrl);
};
if (this.state.account && this.state.account.token) {
doConnect(this.state.account.token);
} else {
// Token may not be in account (auto-login path), fetch it
this.req('/token').then(function(r) {
if (r.success && r.token) doConnect(r.token);
else self._startWsFallback();
});
}
}
_connectWs(url) {
if (this._ws) {
try { this._ws.close(); } catch(e) {}
this._ws = null;
}
this._lastWsUrl = url;
try {
this._ws = new WebSocket(url);
} catch(e) {
console.warn('[WS] Failed to create WebSocket:', e);
this._startWsFallback();
return;
}
this._ws.onmessage = this._onWsMessage.bind(this);
this._ws.onclose = this._onWsClose.bind(this);
this._ws.onerror = function(e) {
console.warn('[WS] Error');
};
this._ws.onopen = function() {
console.log('[WS] Connected');
app._wsFallbackPolling = false;
if (app._wsReconnectTimer) {
clearTimeout(app._wsReconnectTimer);
app._wsReconnectTimer = null;
}
};
}
_onWsMessage(e) {
try {
var msg = JSON.parse(e.data);
switch (msg.type) {
case 'friends_update':
this.state.friends = msg.friends || [];
this.renderFriends();
break;
case 'friend_status':
this.loadFriends();
break;
case 'requests_update':
this.state.friendRequests = msg.requests || [];
this.renderFriendRequests();
break;
case 'clients_update':
this._renderAdminClients(msg.clients || []);
break;
case 'pong':
break;
}
} catch (err) {
console.warn('[WS] Bad message:', err);
}
}
_onWsClose() {
console.log('[WS] Disconnected');
// Don't reconnect if intentionally closed (logout)
if (!this._ws && this._lastWsUrl) {
this._wsReconnectTimer = setTimeout(function() {
if (app.state.account) app._connectWs(app._lastWsUrl);
}, 5000);
}
this._ws = null;
}
_startWsFallback() {
if (this._wsFallbackPolling) return;
this._wsFallbackPolling = true;
console.warn('[WS] Falling back to polling');
this.loadFriends();
if (this._friendsPollInterval) clearInterval(this._friendsPollInterval);
this._friendsPollInterval = setInterval(this.loadFriends.bind(this), 10000);
}
_stopWsFallback() {
this._wsFallbackPolling = false;
if (this._friendsPollInterval) {
clearInterval(this._friendsPollInterval);
this._friendsPollInterval = null;
}
}
closeWebSocket() {
if (this._ws) {
try { this._ws.close(); } catch(e) {}
this._ws = null;
}
if (this._wsReconnectTimer) {
clearTimeout(this._wsReconnectTimer);
this._wsReconnectTimer = null;
}
if (this._wsStatusTimer) {
clearInterval(this._wsStatusTimer);
this._wsStatusTimer = null;
}
if (this._adminClientsTimer) {
clearTimeout(this._adminClientsTimer);
this._adminClientsTimer = null;
}
this._stopWsFallback();
}
_sendWs(msg) {
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
try { this._ws.send(JSON.stringify(msg)); } catch(e) {}
}
}
setLocale(lang) { setLocale(lang) {
_currentLocale = lang; _currentLocale = lang;
_localeData = LOCALES[lang] || LOCALES.en; _localeData = LOCALES[lang] || LOCALES.en;
@@ -396,6 +539,9 @@ class ZernMCLauncher {
const c = document.getElementById('bg-canvas'); const c = document.getElementById('bg-canvas');
const ctx = c.getContext('2d'); const ctx = c.getContext('2d');
let mx = 0, my = 0, ox = 0, oy = 0; let mx = 0, my = 0, ox = 0, oy = 0;
let lastMove = Date.now();
let active = true;
let lastFrame = 0;
const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); }; const resize = () => { c.width = window.innerWidth; c.height = window.innerHeight; draw(); };
const draw = () => { const draw = () => {
@@ -410,15 +556,23 @@ class ZernMCLauncher {
window.addEventListener('mousemove', e => { window.addEventListener('mousemove', e => {
mx = (e.clientX / innerWidth - 0.5) * 2; mx = (e.clientX / innerWidth - 0.5) * 2;
my = (e.clientY / innerHeight - 0.5) * 2; my = (e.clientY / innerHeight - 0.5) * 2;
lastMove = Date.now();
if (!active) { active = true; requestAnimationFrame(anim); }
}); });
const anim = () => { const anim = (now) => {
if (now - lastFrame < 16) { requestAnimationFrame(anim); return; }
lastFrame = now;
if (Date.now() - lastMove > 2000) {
active = false;
return;
}
ox += (mx * 0.3 - ox) * 0.04; ox += (mx * 0.3 - ox) * 0.04;
oy += (my * 0.3 - oy) * 0.04; oy += (my * 0.3 - oy) * 0.04;
draw(); draw();
requestAnimationFrame(anim); requestAnimationFrame(anim);
}; };
resize(); resize();
anim(); requestAnimationFrame(anim);
} }
// ==================== API ==================== // ==================== API ====================
@@ -489,7 +643,6 @@ class ZernMCLauncher {
e.preventDefault(); e.preventDefault();
const username = document.getElementById('username').value.trim(); const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value; const password = document.getElementById('password').value;
console.log('handleLogin: attempting login for', username);
const errEl = document.getElementById('login-error'); const errEl = document.getElementById('login-error');
const btn = document.getElementById('login-btn'); const btn = document.getElementById('login-btn');
const label = btn.querySelector('.btn-label'); const label = btn.querySelector('.btn-label');
@@ -497,6 +650,26 @@ class ZernMCLauncher {
if (!username || !password) { this.showLoginError(t('toast.enterCredentials')); return; } if (!username || !password) { this.showLoginError(t('toast.enterCredentials')); return; }
if (this._registerMode) {
var confirmPw = document.getElementById('confirm-password').value;
if (password !== confirmPw) { this.showLoginError('Passwords do not match'); return; }
btn.disabled = true;
label.textContent = 'Registering...';
spinner.classList.remove('hidden');
var reg = await this.req('/register', { method: 'POST', body: JSON.stringify({ username, password }) });
btn.disabled = false;
spinner.classList.add('hidden');
if (reg.success) {
this.state.account = reg.data;
this.enterMain();
this.toast(tr('toast.accountCreated', null, {username: reg.data.username}), 'success');
} else {
label.textContent = 'Register';
this.showLoginError(reg.error || t('toast.loginFailed'));
}
return;
}
btn.disabled = true; btn.disabled = true;
label.textContent = t('login.signingIn'); label.textContent = t('login.signingIn');
spinner.classList.remove('hidden'); spinner.classList.remove('hidden');
@@ -534,9 +707,30 @@ class ZernMCLauncher {
showLogin() { showLogin() {
document.getElementById('login-screen').classList.remove('hidden'); document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden'); document.getElementById('main-screen').classList.add('hidden');
this._registerMode = false;
}
toggleLoginMode() {
this._registerMode = !this._registerMode;
var btn = document.getElementById('login-btn');
var label = btn.querySelector('.btn-label');
var link = document.getElementById('login-toggle-link');
var confirmField = document.getElementById('confirm-password-field');
if (this._registerMode) {
label.textContent = 'Register';
link.textContent = 'Already have an account? Sign In';
confirmField.style.display = '';
document.querySelector('.login-brand .brand-sub').textContent = 'Create a new account';
} else {
label.textContent = t('login.title');
link.textContent = t('login.register');
confirmField.style.display = 'none';
document.querySelector('.login-brand .brand-sub').textContent = 'Launcher ' + document.getElementById('version').textContent;
}
} }
async logout() { async logout() {
this.closeWebSocket();
this.state.selectedPack = null; this.state.selectedPack = null;
this.state.instances = []; this.state.instances = [];
this.state.account = null; this.state.account = null;
@@ -561,6 +755,7 @@ class ZernMCLauncher {
this.loadNews(); this.loadNews();
this.loadFriends(); this.loadFriends();
this.loadPlaytimeStats(); this.loadPlaytimeStats();
this.initWebSocket();
this.startFriendStatusHeartbeat(); this.startFriendStatusHeartbeat();
this.enhanceSelects(); this.enhanceSelects();
this.updateAdminNav(); this.updateAdminNav();
@@ -953,6 +1148,20 @@ class ZernMCLauncher {
} }
} }
async adminLoadDashboard() {
var r = await this.req('/admin/stats');
if (r.success) {
var el = document.getElementById('ad-total-users');
if (el) el.textContent = r.total_users || '-';
el = document.getElementById('ad-active-passes');
if (el) el.textContent = r.active_passes || '-';
el = document.getElementById('ad-online-now');
if (el) el.textContent = r.online_now !== undefined ? r.online_now : '-';
el = document.getElementById('ad-new-users');
if (el) el.textContent = r.recent_registrations_7d || '-';
}
}
switchAdminTab(tab) { switchAdminTab(tab) {
document.querySelectorAll('.admin-tab').forEach(function(t) { document.querySelectorAll('.admin-tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.atab === tab); t.classList.toggle('active', t.dataset.atab === tab);
@@ -960,6 +1169,7 @@ class ZernMCLauncher {
document.querySelectorAll('.admin-tab-content').forEach(function(c) { document.querySelectorAll('.admin-tab-content').forEach(function(c) {
c.classList.toggle('active', c.id === 'atab-' + tab); c.classList.toggle('active', c.id === 'atab-' + tab);
}); });
this.adminLoadDashboard();
if (tab === 'clients') this.adminLoadClients(); if (tab === 'clients') this.adminLoadClients();
if (tab === 'mods') this.adminLoadMods(); if (tab === 'mods') this.adminLoadMods();
if (tab === 'users') this.adminClearUser(); if (tab === 'users') this.adminClearUser();
@@ -968,17 +1178,10 @@ class ZernMCLauncher {
} }
// --- Clients --- // --- Clients ---
async adminLoadClients() { _renderAdminClients(clients) {
var el = document.getElementById('admin-clients-list'); var el = document.getElementById('admin-clients-list');
if (!el) return; if (!el) return;
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>'; if (!clients || !clients.length) {
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>'; el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
return; return;
} }
@@ -990,7 +1193,26 @@ class ZernMCLauncher {
+ '</div>'; + '</div>';
}); });
el.innerHTML = html; el.innerHTML = html;
setTimeout(function() { app.adminLoadClients(); }, 5000); }
async adminLoadClients() {
var el = document.getElementById('admin-clients-list');
if (!el) return;
// If WS is connected, wait for push updates instead of polling
if (this._ws && this._ws.readyState === WebSocket.OPEN) {
el.innerHTML = '<div class="admin-loading">' + t('admin.loading') + '</div>';
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>';
this._adminClientsTimer = setTimeout(function() { app.adminLoadClients(); }, 5000);
return;
}
var clients = r.clients || r.data || [];
this._renderAdminClients(clients);
this._adminClientsTimer = setTimeout(function() { app.adminLoadClients(); }, 5000);
} }
// --- Mods --- // --- Mods ---
@@ -1153,14 +1375,93 @@ class ZernMCLauncher {
} }
var html = ''; var html = '';
passes.forEach(function(p) { passes.forEach(function(p) {
html += '<div class="admin-item">' var isActive = p.is_active !== false;
+ '<span class="admin-item-name">' + app.esc(p.code || p.name || '?') + '</span>' html += '<div class="admin-item" style="opacity:' + (isActive ? '1' : '0.4') + '">'
+ '<span class="admin-item-meta">' + (p.username ? app.esc(p.username) : '') + '</span>' + '<span class="admin-item-name" style="font-family:var(--mono);font-size:12px">' + app.esc(p.code || '?') + '</span>'
+ '</div>'; + '<span class="admin-item-meta">' + (p.owner ? app.esc(p.owner) : '') + (p.uses !== undefined ? ' uses:' + p.uses + '/' + (p.max_uses || '-') : '') + '</span>'
+ '<div class="admin-item-actions">'
+ '<button class="btn-secondary btn-sm" onclick="app.adminTogglePassEdit(\'' + app.esc(p.code) + '\', ' + (isActive ? 'true' : 'false') + ', ' + (p.max_uses || 1) + ')">Edit</button>'
+ '<button class="btn-secondary btn-sm btn-danger" onclick="app.adminDeletePass(\'' + app.esc(p.code) + '\')">' + t('admin.delete') + '</button>'
+ '</div></div>';
}); });
el.innerHTML = html; el.innerHTML = html;
} }
async adminCreatePass() {
var days = parseInt(document.getElementById('pass-create-days').value) || 30;
var maxUses = parseInt(document.getElementById('pass-create-uses').value) || 1;
var resultEl = document.getElementById('admin-pass-create-result');
var r = await this.req('/admin/passes/create', {
method: 'POST',
body: JSON.stringify({ expires_days: days, max_uses: maxUses })
});
resultEl.classList.remove('hidden');
if (r.success) {
resultEl.textContent = 'Pass created: ' + r.code;
resultEl.className = 'admin-status success';
this.adminLoadPasses();
} else {
resultEl.textContent = r.error || t('admin.error');
resultEl.className = 'admin-status error';
}
}
adminTogglePassEdit(code, isActive, maxUses) {
var existing = document.getElementById('pass-edit-' + code.replace(/[^A-Z0-9]/g, ''));
if (existing) { existing.remove(); return; }
var items = document.querySelectorAll('#admin-passes-list .admin-item');
var target = null;
for (var i = 0; i < items.length; i++) {
if (items[i].querySelector('.admin-item-name') && items[i].querySelector('.admin-item-name').textContent.trim() === code) {
target = items[i]; break;
}
}
if (!target) return;
var form = document.createElement('div');
form.id = 'pass-edit-' + code.replace(/[^A-Z0-9]/g, '');
form.className = 'pass-edit-form';
form.innerHTML = '<span class="pass-code-display">' + app.esc(code) + '</span>'
+ '<span style="font-size:11px;color:var(--text-muted)">Active:</span>'
+ '<select id="pe-active-' + code.replace(/[^A-Z0-9]/g, '') + '">'
+ '<option value="1"' + (isActive ? ' selected' : '') + '>Yes</option>'
+ '<option value="0"' + (!isActive ? ' selected' : '') + '>No</option>'
+ '</select>'
+ '<span style="font-size:11px;color:var(--text-muted)">Max uses:</span>'
+ '<input type="number" id="pe-uses-' + code.replace(/[^A-Z0-9]/g, '') + '" value="' + maxUses + '" min="1" max="10" style="width:60px">'
+ '<button class="btn-primary btn-sm" onclick="app.adminUpdatePass(\'' + app.esc(code) + '\')">Save</button>';
target.parentNode.insertBefore(form, target.nextSibling);
}
async adminUpdatePass(code) {
var safeKey = code.replace(/[^A-Z0-9]/g, '');
var isActive = document.getElementById('pe-active-' + safeKey).value === '1';
var maxUses = parseInt(document.getElementById('pe-uses-' + safeKey).value) || 1;
var r = await this.req('/admin/passes/update', {
method: 'PUT',
body: JSON.stringify({ code: code, max_uses: maxUses, is_active: isActive, expires_days: null })
});
if (r.success) {
this.toast('Pass updated', 'success');
this.adminLoadPasses();
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
async adminDeletePass(code) {
if (!confirm('Delete pass ' + code + '?')) return;
var r = await this.req('/admin/passes/delete', {
method: 'POST',
body: JSON.stringify({ code: code })
});
if (r.success) {
this.toast('Pass deleted', 'success');
this.adminLoadPasses();
} else {
this.toast(r.error || t('admin.error'), 'error');
}
}
// --- News --- // --- News ---
async adminLoadNewsList() { async adminLoadNewsList() {
var el = document.getElementById('admin-news-list'); var el = document.getElementById('admin-news-list');
@@ -1209,6 +1510,7 @@ class ZernMCLauncher {
document.getElementById('admin-news-title').value = ''; document.getElementById('admin-news-title').value = '';
document.getElementById('admin-news-version').value = ''; document.getElementById('admin-news-version').value = '';
document.getElementById('admin-news-body').value = ''; document.getElementById('admin-news-body').value = '';
document.getElementById('admin-news-preview').innerHTML = 'Preview will appear here...';
this.adminLoadNewsList(); this.adminLoadNewsList();
} else { } else {
status.textContent = r.error || t('admin.error'); status.textContent = r.error || t('admin.error');
@@ -1216,6 +1518,17 @@ class ZernMCLauncher {
} }
} }
updateNewsPreview() {
var body = document.getElementById('admin-news-body').value;
var preview = document.getElementById('admin-news-preview');
if (!preview) return;
if (!body.trim()) {
preview.innerHTML = 'Preview will appear here...';
return;
}
preview.innerHTML = this.mdToHtml(body);
}
async adminDeleteNews(idx) { async adminDeleteNews(idx) {
var r = await this.req('/admin/news/delete', { var r = await this.req('/admin/news/delete', {
method: 'POST', method: 'POST',
@@ -1584,7 +1897,6 @@ class ZernMCLauncher {
async installZernMCPack() { async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value; const packName = document.getElementById('zernmc-pack-select').value;
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; } if (!packName) { this.toast(t('select.selectPack'), 'error'); return; }
// Use server pack name as instance name (no local naming)
const instanceName = packName; const instanceName = packName;
const r = await this.req('/install', { const r = await this.req('/install', {
@@ -1595,7 +1907,9 @@ class ZernMCLauncher {
this.toast(t('toast.installing'), 'info'); this.toast(t('toast.installing'), 'info');
this.startProgressPoll(); this.startProgressPoll();
} else { } else {
this.toast(r.error || t('toast.launchFailed'), 'error'); var errMsg = r.error || t('toast.installFailed');
console.error('[INSTALL] Start error:', errMsg);
this.toast(errMsg, 'error');
} }
} }
@@ -1626,7 +1940,8 @@ class ZernMCLauncher {
if (r.success && r.data) { if (r.success && r.data) {
const p = document.getElementById('install-progress'); const p = document.getElementById('install-progress');
p.classList.remove('hidden'); p.classList.remove('hidden');
document.getElementById('progress-fill').style.width = (r.data.percent || 0) + '%'; var pct = r.data.percent || 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-label').textContent = r.data.label || 'Installing...'; document.getElementById('progress-label').textContent = r.data.label || 'Installing...';
const stageInfo = document.getElementById('progress-stage'); const stageInfo = document.getElementById('progress-stage');
if (r.data.stageName && r.data.stageCount > 1) { if (r.data.stageName && r.data.stageCount > 1) {
@@ -1638,10 +1953,15 @@ class ZernMCLauncher {
if (!r.data.inProgress) { if (!r.data.inProgress) {
this.stopProgressPoll(); this.stopProgressPoll();
this.hideInstallModal(); this.hideInstallModal();
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error')); var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && (r.data.label.startsWith('Error') || r.data.label.startsWith('Installation failed')));
this.toast(failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete'), failed ? 'error' : 'success'); var msg = failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete');
this.toast(msg, failed ? 'error' : 'success');
if (failed) console.error('[INSTALL] Failed:', r.data.stageName, r.data.label);
if (!failed) await this.loadInstances(); if (!failed) await this.loadInstances();
} }
} else if (!r.success) {
this.stopProgressPoll();
this.toast(r.error || t('toast.installFailed'), 'error');
} }
}, 500); }, 500);
} }
@@ -1701,10 +2021,23 @@ class ZernMCLauncher {
if (!el) return; if (!el) return;
var cores = data.cpuCores || '-'; var cores = data.cpuCores || '-';
var ramGB = data.totalRamMB ? (data.totalRamMB / 1024).toFixed(1) : '-'; var ramGB = data.totalRamMB ? (data.totalRamMB / 1024).toFixed(1) : '-';
var os = this._detectOS();
var flags = data.systemBasedJvm ? t('settings.systemJvm.enabled') : t('settings.systemJvm.disabled'); var flags = data.systemBasedJvm ? t('settings.systemJvm.enabled') : t('settings.systemJvm.disabled');
if (data.systemBasedJvm) {
flags += ' | ' + os;
}
el.textContent = tr('settings.systemJvm.info', null, {cores: cores, ram: ramGB, flags: flags}); el.textContent = tr('settings.systemJvm.info', null, {cores: cores, ram: ramGB, flags: flags});
} }
_detectOS() {
var p = navigator.platform || '';
if (p.indexOf('Win') !== -1) return 'Windows';
if (p.indexOf('Mac') !== -1) return 'macOS';
if (p.indexOf('Linux') !== -1) return 'Linux';
if (p.indexOf('FreeBSD') !== -1) return 'FreeBSD';
return p || 'Unknown';
}
async saveSettings() { async saveSettings() {
try { try {
const ram = document.getElementById('ram-slider').value; const ram = document.getElementById('ram-slider').value;
@@ -1747,13 +2080,18 @@ class ZernMCLauncher {
} }
startFriendStatusHeartbeat() { startFriendStatusHeartbeat() {
if (this._friendsPollInterval) clearInterval(this._friendsPollInterval); // Send online status via WebSocket if connected, fallback to REST
this._friendsPollInterval = setInterval(this.loadFriends.bind(this), 10000); var sendStatus = function() {
// Send online status var pack = app.state.selectedPack ? app.state.selectedPack.name : null;
this.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: this.state.selectedPack ? this.state.selectedPack.name : null }) }); if (app._ws && app._ws.readyState === WebSocket.OPEN) {
setInterval(function() { app._sendWs({ type: 'status', current_pack: pack || '' });
app.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: app.state.selectedPack ? app.state.selectedPack.name : null }) }); } else {
}, 60000); app.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: pack }) });
}
};
sendStatus();
if (this._wsStatusTimer) clearInterval(this._wsStatusTimer);
this._wsStatusTimer = setInterval(sendStatus, 60000);
} }
renderFriends() { renderFriends() {
+153 -7
View File
@@ -176,11 +176,18 @@ body {
padding: 16px 12px; padding: 16px 12px;
} }
.sidebar-top { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } .sidebar-header {
flex-shrink: 0;
}
.sidebar-scroll {
flex: 1; display: flex; flex-direction: column; overflow-y: auto;
margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);
}
.sidebar-brand { .sidebar-brand {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;
padding: 4px 8px 16px; border-bottom: 1px solid var(--border); padding: 0 8px 12px;
} }
.sidebar-brand-text { display: flex; flex-direction: column; } .sidebar-brand-text { display: flex; flex-direction: column; }
.sidebar-brand-name { font-size: 16px; font-weight: 700; } .sidebar-brand-name { font-size: 16px; font-weight: 700; }
@@ -188,7 +195,6 @@ body {
.sidebar-nav { .sidebar-nav {
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
padding-bottom: 16px; border-bottom: 1px solid var(--border);
} }
.sidebar-nav-primary, .sidebar-nav-secondary { .sidebar-nav-primary, .sidebar-nav-secondary {
display: flex; gap: 4px; display: flex; gap: 4px;
@@ -214,8 +220,9 @@ body {
.pack-list { .pack-list {
display: flex; flex-direction: column; gap: 3px; display: flex; flex-direction: column; gap: 3px;
overflow-y: auto; max-height: calc((100vh - 460px) / 2); overflow-y: auto;
min-height: 40px; max-height: min(calc(50vh - 80px), 280px);
min-height: 60px;
} }
.pack-list:empty::after { .pack-list:empty::after {
content: 'No packs'; display: block; padding: 12px 8px; content: 'No packs'; display: block; padding: 12px 8px;
@@ -291,8 +298,17 @@ body {
overflow-y: auto; overflow-y: auto;
} }
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; } .view {
.view.active { display: flex; } display: none; flex-direction: column; height: 100%; overflow-y: auto;
opacity: 0; transform: translateY(8px) scale(0.98);
transition: opacity 0.25s ease, transform 0.25s ease;
pointer-events: none;
}
.view.active {
display: flex;
opacity: 1; transform: translateY(0) scale(1);
pointer-events: auto;
}
.view-header { .view-header {
display: flex; align-items: flex-start; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
@@ -881,3 +897,133 @@ body {
.admin-news-info { flex: 1; min-width: 0; } .admin-news-info { flex: 1; min-width: 0; }
.admin-news-title { font-size: 13px; font-weight: 500; } .admin-news-title { font-size: 13px; font-weight: 500; }
.admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } .admin-news-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
/* Admin dashboard stat cards */
.admin-dashboard {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px; margin-bottom: 16px;
}
.admin-stat-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-md); padding: 16px; text-align: center;
}
.admin-stat-value {
font-size: 28px; font-weight: 800; color: var(--accent); line-height: 1.2;
}
.admin-stat-label {
font-size: 11px; color: var(--text-muted); text-transform: uppercase;
letter-spacing: 0.5px; margin-top: 4px;
}
/* Pass CRUD UI */
.pass-create-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap;
}
.pass-create-row input[type="number"] {
width: 80px; 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;
}
.pass-create-row input[type="number"]:focus { border-color: var(--accent); }
.pass-edit-form {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-sm); margin-bottom: 6px; flex-wrap: wrap;
}
.pass-edit-form .pass-code-display {
font-family: var(--mono); font-size: 12px; color: var(--accent);
flex: 1; min-width: 100px;
}
.pass-edit-form select, .pass-edit-form input[type="number"] {
padding: 4px 8px; background: var(--bg-surface);
border: 1px solid var(--border-light); border-radius: 4px;
color: var(--text); font-size: 11px; outline: none; font-family: var(--font);
}
.pass-edit-form select:focus, .pass-edit-form input[type="number"]:focus { border-color: var(--accent); }
/* MD preview split-pane */
.md-editor-row {
display: flex; gap: 12px; margin-bottom: 8px;
}
.md-editor-col {
flex: 1; display: flex; flex-direction: column;
}
.md-editor-col textarea {
flex: 1; min-height: 160px;
}
.md-preview {
flex: 1; min-height: 160px; padding: 10px;
background: var(--bg-card); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); font-size: 13px; line-height: 1.6;
color: var(--text-secondary); overflow-y: auto;
}
.md-preview p { margin-bottom: 8px; }
.md-preview h1, .md-preview h2, .md-preview h3 { color: var(--text); margin: 12px 0 6px; }
.md-preview code {
background: var(--bg-inset); padding: 2px 6px; border-radius: 3px;
font-family: var(--mono); font-size: 12px;
}
.md-preview pre {
background: var(--bg-inset); padding: 10px; border-radius: var(--radius-sm);
overflow-x: auto; margin: 8px 0;
}
.md-preview pre code { background: none; padding: 0; }
.md-preview strong { color: var(--text); }
.md-preview a { color: var(--accent); text-decoration: underline; }
.md-preview ul, .md-preview ol { padding-left: 20px; margin: 8px 0; }
.md-preview li { margin-bottom: 4px; }
.md-preview blockquote {
border-left: 3px solid var(--accent); padding-left: 12px;
color: var(--text-muted); margin: 8px 0; font-style: italic;
}
.md-preview img { max-width: 100%; border-radius: var(--radius-sm); margin: 8px 0; }
/* Registration */
.login-extra {
text-align: center; margin-top: 12px;
}
.login-extra a {
color: var(--text-muted); font-size: 12px; cursor: pointer;
text-decoration: none; transition: var(--transition);
}
.login-extra a:hover { color: var(--accent); }
.login-extra .sep {
color: var(--border-light); margin: 0 8px;
}
.btn-secondary-full {
width: 100%; padding: 12px; border: none; border-radius: var(--radius-sm);
background: var(--bg-card); border: 1px solid var(--border-light);
color: var(--text-secondary); font-size: 14px; font-weight: 500; cursor: pointer;
font-family: var(--font); transition: var(--transition); text-align: center;
}
.btn-secondary-full:hover { background: var(--bg-card-hover); color: var(--text); border-color: var(--border); }
/* Login form register fields */
.login-form .field-group {
display: flex; gap: 8px;
}
.login-form .field-group .field {
flex: 1;
}
.login-form .field-group input {
width: 100%; padding: 14px 14px; font-size: 14px;
background: var(--bg-surface); border: 1px solid var(--border-light);
border-radius: var(--radius-sm); color: var(--text);
font-family: var(--font); transition: var(--transition);
outline: none;
}
.login-form .field-group input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.login-form .field-group label {
position: absolute; top: 50%; left: 14px; transform: translateY(-50%);
font-size: 13px; color: var(--text-muted);
transition: var(--transition); pointer-events: none;
background: var(--bg-elevated); padding: 0 4px;
}
.login-form .field-group .field input:focus + label,
.login-form .field-group .field input:not(:placeholder-shown) + label {
top: 0; font-size: 11px; color: var(--accent);
}
+49 -29
View File
@@ -68,36 +68,11 @@
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>
<version>2.15.1</version> <version>2.15.1</version>
</dependency> </dependency>
<!-- JavaFX for Windows --> <!-- JCEF (Chromium Embedded Framework) -->
<dependency> <dependency>
<groupId>org.openjfx</groupId> <groupId>me.friwi</groupId>
<artifactId>javafx-controls</artifactId> <artifactId>jcefmaven</artifactId>
<version>21</version> <version>146.0.10</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency> </dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -156,6 +131,51 @@
</properties> </properties>
</profile> </profile>
<!-- ==================== JAVAFX (optional) ==================== -->
<profile>
<id>javafx</id>
<activation>
<property>
<name>useJavaFX</name>
<value>true</value>
</property>
</activation>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-media</artifactId>
<version>21</version>
<classifier>win</classifier>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
<!-- ==================== ZERNMC ==================== --> <!-- ==================== ZERNMC ==================== -->
<profile> <profile>
<id>zernmc</id> <id>zernmc</id>
+215 -6
View File
@@ -43,6 +43,32 @@ class BanUserRequest(BaseModel):
days: int = Field(..., ge=1, le=365) days: int = Field(..., ge=1, le=365)
reason: str reason: str
class CreatePassCodeRequest(BaseModel):
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class UpdatePassRequest(BaseModel):
code: str
expires_days: Optional[int] = None
max_uses: int = Field(1, ge=1, le=10)
is_active: bool = True
class DeletePassRequest(BaseModel):
code: str
class CreatePassRequest(BaseModel):
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class UpdatePassRequest(BaseModel):
code: str
expires_days: Optional[int] = None
max_uses: int = Field(1, ge=1, le=10)
is_active: bool = True
class DeletePassRequest(BaseModel):
code: str
# ====================== ЭНДПОИНТЫ ====================== # ====================== ЭНДПОИНТЫ ======================
@router.get("/users") @router.get("/users")
@@ -533,10 +559,16 @@ async def get_admin_stats(
SELECT COUNT(*) as count FROM users WHERE created_at > ? SELECT COUNT(*) as count FROM users WHERE created_at > ?
""", (week_ago,)).fetchone()["count"] """, (week_ago,)).fetchone()["count"]
# Онлайн пользователи
online_now = conn.execute("""
SELECT COUNT(*) as count FROM user_status WHERE is_online = 1
""").fetchone()["count"]
return { return {
"total_users": total_users, "total_users": total_users,
"active_passes": active_passes, "active_passes": active_passes,
"banned_users": banned_users, "banned_users": banned_users,
"online_now": online_now,
"recent_registrations_7d": recent_registrations, "recent_registrations_7d": recent_registrations,
"roles_distribution": [ "roles_distribution": [
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]} {"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
@@ -550,6 +582,85 @@ async def get_admin_stats(
} }
# ====================== PASS CRUD ======================
@router.post("/passes/create")
async def admin_create_pass(
body: CreatePassCodeRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Create a new pass code"""
ip = request.client.host if request.client else "unknown"
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
with get_db() as conn:
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_at)
VALUES (?, ?, ?, ?, 1, 0, ?)
""", (pass_code, 'admin', expires_at, body.max_uses, now))
conn.commit()
log_audit(current_user["id"], "pass_create",
f"Created pass {pass_code[:8]}... (expires: {body.expires_days}d, max_uses: {body.max_uses})", ip)
return {"success": True, "code": pass_code, "expires_at": expires_at, "max_uses": body.max_uses}
@router.put("/passes/update")
async def admin_update_pass(
body: UpdatePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Update an existing pass"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
expires_at = time.time() + (body.expires_days * 86400) if body.expires_days else None
conn.execute("""
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
WHERE code = ?
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
conn.commit()
log_audit(current_user["id"], "pass_update",
f"Updated pass {body.code[:8]}... (active: {body.is_active})", ip)
return {"success": True}
@router.post("/passes/delete")
async def admin_delete_pass(
body: DeletePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Delete (deactivate) a pass"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
conn.commit()
log_audit(current_user["id"], "pass_delete",
f"Deleted pass {body.code[:8]}...", ip)
return {"success": True}
# ====================== WHITELIST MODS ====================== # ====================== WHITELIST MODS ======================
WHITELIST_DIR = Path(__file__).parent / "whitelist" WHITELIST_DIR = Path(__file__).parent / "whitelist"
@@ -656,15 +767,21 @@ async def remove_whitelist_mod(
@router.get("/clients") @router.get("/clients")
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))): async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
"""List online clients""" """List online clients with 5-minute stale timeout"""
with get_db() as conn: with get_db() as conn:
# Auto-clean stale online status (no heartbeat > 5 min)
stale_cutoff = time.time() - 300
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
conn.commit()
rows = conn.execute(""" rows = conn.execute("""
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
(SELECT s.ip_address FROM user_sessions s
WHERE s.user_id = u.id AND s.is_active = 1
ORDER BY s.created_at DESC LIMIT 1) as ip_address
FROM users u FROM users u
LEFT JOIN user_status us ON u.id = us.user_id 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 WHERE us.is_online = 1
GROUP BY u.id
ORDER BY us.last_seen DESC ORDER BY us.last_seen DESC
""").fetchall() """).fetchall()
clients = [] clients = []
@@ -672,7 +789,7 @@ async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODE
clients.append({ clients.append({
"id": row["id"], "id": row["id"],
"username": row["username"], "username": row["username"],
"online": bool(row["is_online"]), "online": True,
"current_pack": row["current_pack"] or "", "current_pack": row["current_pack"] or "",
"last_seen": str(row["last_seen"]) if row["last_seen"] else None, "last_seen": str(row["last_seen"]) if row["last_seen"] else None,
"ip": row["ip_address"] or "" "ip": row["ip_address"] or ""
@@ -811,6 +928,98 @@ async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODER
return {"success": True, "passes": passes} return {"success": True, "passes": passes}
@router.post("/passes/create")
async def create_pass(
body: CreatePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
ip = request.client.host if request.client else "unknown"
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
with get_db() as conn:
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_by, activated_at)
VALUES (?, 'admin', ?, ?, 1, 0, NULL, ?)
""", (pass_code, expires_at, body.max_uses, now))
conn.commit()
log_audit(
current_user["id"],
"pass_create",
f"Created pass code {pass_code} (expires_days: {body.expires_days}, max_uses: {body.max_uses})",
ip
)
return {"success": True, "code": pass_code}
@router.put("/passes/update")
async def update_pass(
body: UpdatePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute(
"SELECT code FROM passes WHERE code = ?", (body.code,)
).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
expires_at = None
if body.expires_days is not None:
expires_at = time.time() + (body.expires_days * 86400)
conn.execute("""
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
WHERE code = ?
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
conn.commit()
log_audit(
current_user["id"],
"pass_update",
f"Updated pass {body.code} (expires_days: {body.expires_days}, max_uses: {body.max_uses}, is_active: {body.is_active})",
ip
)
return {"success": True}
@router.post("/passes/delete")
async def delete_pass(
body: DeletePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute(
"SELECT code FROM passes WHERE code = ?", (body.code,)
).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
conn.commit()
log_audit(
current_user["id"],
"pass_delete",
f"Deactivated pass {body.code}",
ip
)
return {"success": True}
@router.post("/whitelist/mods/{mod_hash}/delete") @router.post("/whitelist/mods/{mod_hash}/delete")
async def admin_delete_whitelist_mod_adapter( async def admin_delete_whitelist_mod_adapter(
mod_hash: str, mod_hash: str,
+4
View File
@@ -542,6 +542,10 @@ async def logout(current_user: dict = Depends(get_current_user), request: Reques
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?", "UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
(current_user["id"],) (current_user["id"],)
) )
conn.execute(
"UPDATE user_status SET is_online = 0 WHERE user_id = ?",
(current_user["id"],)
)
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip) log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
+23
View File
@@ -116,7 +116,12 @@ async def remove_friend(
@router.get("/friends/list") @router.get("/friends/list")
async def list_friends(current_user: dict = Depends(get_current_user)): async def list_friends(current_user: dict = Depends(get_current_user)):
friends = [] friends = []
stale_cutoff = time.time() - 300
with get_db() as conn: with get_db() as conn:
# Auto-clean stale online
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
conn.commit()
rows = conn.execute(""" rows = conn.execute("""
SELECT u.id, u.username, u.role, SELECT u.id, u.username, u.role,
COALESCE(us.is_online, 0) as online, COALESCE(us.is_online, 0) as online,
@@ -159,6 +164,17 @@ async def list_friend_requests(current_user: dict = Depends(get_current_user)):
}) })
return {"requests": requests} return {"requests": requests}
def get_friends_ids(user_id: int) -> list[int]:
"""Get list of friend user_ids for a given user"""
with get_db() as conn:
rows = conn.execute("""
SELECT CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END as friend_id
FROM friendships f
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
""", (user_id, user_id, user_id)).fetchall()
return [r["friend_id"] for r in rows]
@router.post("/friends/status") @router.post("/friends/status")
async def update_status( async def update_status(
req: StatusUpdateRequest, req: StatusUpdateRequest,
@@ -173,4 +189,11 @@ async def update_status(
current_pack = COALESCE(excluded.current_pack, user_status.current_pack), current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
last_seen = CURRENT_TIMESTAMP last_seen = CURRENT_TIMESTAMP
""", (current_user["id"], int(req.online), req.current_pack or "")) """, (current_user["id"], int(req.online), req.current_pack or ""))
# Broadcast status change to friends via WebSocket
try:
from ws_manager import manager
friend_ids = get_friends_ids(current_user["id"])
await manager.broadcast_status_change(current_user["id"], friend_ids)
except Exception:
pass
return {"status": "ok"} return {"status": "ok"}
+154 -2
View File
@@ -11,7 +11,7 @@ import httpx
import json import json
import structlog import structlog
from cachetools import TTLCache from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
@@ -28,8 +28,9 @@ from log_manager import init_logging
from auth import get_current_user, router as auth_router, init_db, verify_jwt from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission from roles import Permissions, has_permission
from admin_router import router as admin_router from admin_router import router as admin_router
from friends import router as friends_router, init_friends_db from friends import router as friends_router, init_friends_db, get_friends_ids
from playtime import router as playtime_router, init_playtime_db from playtime import router as playtime_router, init_playtime_db
from ws_manager import manager
import asyncio import asyncio
import hashlib import hashlib
@@ -222,8 +223,14 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_sync()) asyncio.create_task(periodic_sync())
# Start WebSocket admin clients broadcaster
broadcaster = asyncio.create_task(admin_clients_broadcaster())
yield yield
# Cancel WebSocket broadcaster
broadcaster.cancel()
# Cleanup proxy client # Cleanup proxy client
if proxy_client: if proxy_client:
await proxy_client.aclose() await proxy_client.aclose()
@@ -925,6 +932,151 @@ app.include_router(friends_router)
app.include_router(playtime_router) app.include_router(playtime_router)
# ====================== WEBSOCKET ======================
async def admin_clients_broadcaster():
"""Periodically push client list to connected admins via WebSocket"""
while True:
await asyncio.sleep(5)
try:
from auth import get_db
from roles import ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
admin_ids = [uid for uid in list(manager._connections.keys())]
if not admin_ids:
continue
# Check which are actually admins
with get_db() as conn:
admin_rows = conn.execute(
"SELECT id FROM users WHERE id IN ({}) AND role >= ?".format(
",".join("?" for _ in admin_ids)),
admin_ids + [ROLE_MODERATOR]
).fetchall()
real_admins = [r["id"] for r in admin_rows]
if not real_admins:
continue
stale_cutoff = time.time() - 300
with get_db() as conn:
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
conn.commit()
rows = conn.execute("""
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
(SELECT s.ip_address FROM user_sessions s
WHERE s.user_id = u.id AND s.is_active = 1
ORDER BY s.created_at DESC LIMIT 1) as ip_address
FROM users u
JOIN user_status us ON u.id = us.user_id
WHERE us.is_online = 1
ORDER BY us.last_seen DESC
""").fetchall()
clients = []
for row in rows:
clients.append({
"id": row["id"], "username": row["username"],
"online": True, "current_pack": row["current_pack"] or "",
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
"ip": row["ip_address"] or ""
})
await manager.broadcast_admin_clients(clients, real_admins)
except Exception as exc:
logger.debug("admin_clients_broadcaster error", error=str(exc))
@app.websocket("/ws")
async def websocket_endpoint(
ws: WebSocket,
token: str = Query(...),
):
try:
payload = verify_jwt(token)
if payload is None:
await ws.close(code=4001, reason="Invalid token")
return
user_id = payload.get("user_id")
if user_id is None:
await ws.close(code=4001, reason="Invalid token payload")
return
except Exception:
await ws.close(code=4001, reason="Invalid token")
return
await manager.connect(user_id, ws)
# Send initial friends list
try:
with get_db() as conn:
cutoff = time.time() - 300
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (cutoff,))
conn.commit()
rows = conn.execute("""
SELECT u.id, u.username, u.role,
COALESCE(us.is_online, 0) as online,
COALESCE(us.current_pack, '') as current_pack,
us.last_seen
FROM friendships f
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
LEFT JOIN user_status us ON u.id = us.user_id
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
""", (user_id, user_id, user_id))
friends = []
for row in rows:
friends.append({
"id": row["id"], "username": row["username"],
"role": row["role"], "online": bool(row["online"]),
"current_pack": row["current_pack"],
"last_seen": str(row["last_seen"]) if row["last_seen"] else None
})
await manager.send_json(user_id, {"type": "friends_update", "friends": friends})
# Send initial friend requests
with get_db() as conn:
req_rows = conn.execute("""
SELECT u.id, u.username, u.role, f.created_at
FROM friendships f
JOIN users u ON f.requester_id = u.id
WHERE f.target_id = ? AND f.status = 'pending'
""", (user_id,))
requests = []
for row in req_rows:
requests.append({
"id": row["id"], "username": row["username"],
"role": row["role"], "created_at": str(row["created_at"]) if row["created_at"] else None
})
await manager.send_json(user_id, {"type": "requests_update", "requests": requests})
except Exception as exc:
logger.warning("WS initial data error", error=str(exc))
try:
while True:
data = await ws.receive_text()
try:
msg = json.loads(data)
msg_type = msg.get("type")
if msg_type == "ping":
await ws.send_text(json.dumps({"type": "pong"}))
elif msg_type == "status":
current_pack = msg.get("current_pack", "")
with get_db() as conn:
conn.execute("""
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
VALUES (?, 1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
is_online = 1,
current_pack = COALESCE(?, user_status.current_pack),
last_seen = CURRENT_TIMESTAMP
""", (user_id, current_pack, current_pack))
# Notify friends
friend_ids = get_friends_ids(user_id)
await manager.broadcast_status_change(user_id, friend_ids)
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
pass
except Exception as exc:
logger.warning("WS error", user_id=user_id, error=str(exc))
finally:
manager.disconnect(user_id, ws)
# Monkey patch to catch invalid HTTP requests # Monkey patch to catch invalid HTTP requests
original_data_received = HttpToolsProtocol.data_received original_data_received = HttpToolsProtocol.data_received
+68
View File
@@ -0,0 +1,68 @@
import asyncio
import json
import structlog
import time
from collections import defaultdict
from typing import Optional
from fastapi import WebSocket
logger = structlog.get_logger(__name__)
class ConnectionManager:
def __init__(self):
# user_id -> list of WebSocket connections
self._connections: dict[int, list[WebSocket]] = defaultdict(list)
# user_id -> set of friend user_ids (cached for broadcast)
self._friend_cache: dict[int, set[int]] = {}
self._cleanup_lock = asyncio.Lock()
async def connect(self, user_id: int, ws: WebSocket):
await ws.accept()
self._connections[user_id].append(ws)
logger.info("WS connected", user_id=user_id, total=len(self._connections))
def disconnect(self, user_id: int, ws: WebSocket):
conns = self._connections.get(user_id, [])
if ws in conns:
conns.remove(ws)
if not self._connections.get(user_id):
self._connections.pop(user_id, None)
logger.info("WS disconnected", user_id=user_id, total=len(self._connections))
async def send_json(self, user_id: int, data: dict):
conns = self._connections.get(user_id, [])
if not conns:
return
msg = json.dumps(data)
dead = []
for ws in conns:
try:
await ws.send_text(msg)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(user_id, ws)
async def broadcast_friends_update(self, user_id: int, friends_data: list[dict]):
"""Broadcast friend list update to a specific user"""
await self.send_json(user_id, {
"type": "friends_update",
"friends": friends_data
})
async def broadcast_status_change(self, user_id: int, friends: list[int]):
"""Notify all friends that this user's status changed"""
data = {"type": "friend_status", "user_id": user_id}
for fid in friends:
await self.send_json(fid, data)
async def broadcast_admin_clients(self, clients_data: list[dict], admin_ids: list[int]):
"""Broadcast client list to all connected admins"""
data = {"type": "clients_update", "clients": clients_data}
for aid in admin_ids:
await self.send_json(aid, data)
manager = ConnectionManager()