JCEF migration: add jcefmaven dep, make JavaFX optional, create LogBridge, wire JCEFLauncher entry point
This commit is contained in:
@@ -37,6 +37,7 @@ public class Bootstrap {
|
||||
private static Path javafxPath;
|
||||
private static boolean isCliMode;
|
||||
private static boolean isJfxMode;
|
||||
private static boolean isJcefMode;
|
||||
private static BootstrapUI ui;
|
||||
|
||||
private static Path getLauncherJar() {
|
||||
@@ -54,13 +55,29 @@ public class Bootstrap {
|
||||
log("=== ZernMC Launcher ===");
|
||||
|
||||
List<String> argList = new ArrayList<>(Arrays.asList(args));
|
||||
isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx"));
|
||||
isJfxMode = !isCliMode;
|
||||
isCliMode = argList.contains("--cli") || (System.console() != null && !argList.contains("--jfx") && !argList.contains("--jcef"));
|
||||
// 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")) {
|
||||
argList.add("--cli");
|
||||
}
|
||||
|
||||
log("Mode: " + (isCliMode ? "CLI" : "JFX"));
|
||||
log("Mode: " + (isCliMode ? "CLI" : (isJcefMode ? "JCEF" : "JFX")));
|
||||
|
||||
if (!isCliMode && !GraphicsEnvironment.isHeadless()) {
|
||||
ui = new BootstrapUI();
|
||||
@@ -146,16 +163,26 @@ public class Bootstrap {
|
||||
cmd.add("-Dsun.stderr.encoding=UTF-8");
|
||||
cmd.add("-Dlauncher.server=" + BASE_URL);
|
||||
|
||||
if (Files.exists(javafxPath)) {
|
||||
if (isJfxMode && Files.exists(javafxPath)) {
|
||||
cmd.add("--module-path");
|
||||
cmd.add(javafxPath.toAbsolutePath().toString());
|
||||
cmd.add("--add-modules");
|
||||
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(getLauncherJar().toAbsolutePath().toString());
|
||||
cmd.add("--jfx");
|
||||
cmd.add(isJcefMode ? "--jcef" : "--jfx");
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||
pb.directory(baseDir.toFile());
|
||||
|
||||
+78
-35
@@ -57,36 +57,10 @@
|
||||
<artifactId>commons-io</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JavaFX - Windows -->
|
||||
<!-- JCEF (Chromium Embedded Framework) - auto-downloads natives -->
|
||||
<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>
|
||||
<groupId>me.friwi</groupId>
|
||||
<artifactId>jcefmaven</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test -->
|
||||
@@ -98,8 +72,77 @@
|
||||
</dependency>
|
||||
</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>
|
||||
<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>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
@@ -223,29 +266,29 @@
|
||||
</fileset>
|
||||
</copy>
|
||||
|
||||
<!-- Копируем JavaFX модули в lib/javafx -->
|
||||
<!-- Копируем JavaFX модули в lib/javafx (если есть, иначе JCEF режим) -->
|
||||
<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">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</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">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</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">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</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">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
</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">
|
||||
<include name="*win.jar"/>
|
||||
</fileset>
|
||||
|
||||
@@ -4,7 +4,7 @@ import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.menu.*;
|
||||
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 java.io.IOException;
|
||||
import java.util.List;
|
||||
@@ -35,8 +35,14 @@ public class Main {
|
||||
|
||||
List<String> argList = List.of(args);
|
||||
boolean jfxMode = argList.contains("--jfx");
|
||||
boolean jcefMode = argList.contains("--jcef");
|
||||
boolean cliMode = argList.contains("--cli");
|
||||
|
||||
if (jcefMode) {
|
||||
launchJCEF();
|
||||
return;
|
||||
}
|
||||
|
||||
if (jfxMode) {
|
||||
launchJFX();
|
||||
return;
|
||||
@@ -48,14 +54,27 @@ public class Main {
|
||||
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() {
|
||||
try {
|
||||
System.setProperty("javafx.runtime.version", "21");
|
||||
|
||||
JFXLauncher.main(new String[]{});
|
||||
Class<?> jfxClass = Class.forName("me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher");
|
||||
java.lang.reflect.Method mainMethod = jfxClass.getMethod("main", String[].class);
|
||||
mainMethod.invoke(null, new Object[]{new String[]{}});
|
||||
} catch (Exception e) {
|
||||
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("Try CLI mode: --cli"));
|
||||
}
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
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.LauncherLogger;
|
||||
|
||||
@@ -98,14 +98,14 @@ public class LaunchService {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String timestamped = "[" + java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + line;
|
||||
JFXLauncher.appendGameLog(line);
|
||||
LogBridge.appendGameLog(line);
|
||||
try {
|
||||
logFileOut.write((timestamped + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
logFileOut.flush();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
|
||||
LogBridge.appendGameLog("[Error reading logs: " + e.getMessage() + "]");
|
||||
} finally {
|
||||
try { logFileOut.close(); } catch (Exception ignored) {}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ public class LaunchService {
|
||||
|
||||
process.onExit().thenRun(() -> {
|
||||
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");
|
||||
|
||||
+5
-5
@@ -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.launch.LaunchCommandBuilder;
|
||||
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.LauncherLogger;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
@@ -130,10 +130,10 @@ public class MinecraftLib {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JFXLauncher.appendGameLog(line);
|
||||
LogBridge.appendGameLog(line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
||||
LogBridge.appendGameLog("[Error reading output: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
outThread.setDaemon(true);
|
||||
@@ -144,10 +144,10 @@ public class MinecraftLib {
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
JFXLauncher.appendGameLog("[ERR] " + line);
|
||||
LogBridge.appendGameLog("[ERR] " + line);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
JFXLauncher.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
||||
LogBridge.appendGameLog("[Error reading stderr: " + e.getMessage() + "]");
|
||||
}
|
||||
});
|
||||
errThread.setDaemon(true);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -504,6 +504,7 @@ public class JFXLauncher extends Application {
|
||||
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/token", this::handleToken);
|
||||
server.createContext("/api/admin", this::handleAdmin);
|
||||
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) {
|
||||
try {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,11 +32,15 @@
|
||||
|
||||
<form id="login-form" class="login-form">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
<p id="login-error" class="error-msg hidden"></p>
|
||||
@@ -44,7 +48,9 @@
|
||||
<span class="btn-label" data-i18n="login.title">Sign In</span>
|
||||
<div class="spinner hidden"></div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +65,7 @@
|
||||
<div id="main-screen" class="screen hidden">
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-brand">
|
||||
<svg width="32" height="32" viewBox="0 0 56 56" fill="none">
|
||||
<rect width="56" height="56" rx="14" fill="url(#brandGrad2)"/>
|
||||
@@ -99,7 +105,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-scroll">
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header">
|
||||
<span class="section-title" data-i18n="sidebar.serverPacks">Server Packs</span>
|
||||
@@ -248,6 +256,12 @@
|
||||
<div class="view-header">
|
||||
<h2 class="view-title" data-i18n="admin.title">Admin Panel</h2>
|
||||
</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">
|
||||
<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>
|
||||
@@ -310,6 +324,17 @@
|
||||
</div>
|
||||
<!-- Passes -->
|
||||
<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-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>
|
||||
@@ -331,8 +356,15 @@
|
||||
</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 class="md-editor-row">
|
||||
<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 class="admin-row">
|
||||
<button class="btn-primary btn-sm" onclick="app.adminCreateNews()"><span data-i18n="admin.newsCreateBtn">Publish</span></button>
|
||||
|
||||
@@ -5,6 +5,7 @@ const LOCALES = {
|
||||
'nav.packs': 'Packs', 'nav.news': 'News', 'nav.settings': 'Settings',
|
||||
'login.title': 'Sign In', 'login.username': 'Username', 'login.password': 'Password',
|
||||
'login.hint': 'New account will be created automatically on first login',
|
||||
'login.register': 'No account? Register',
|
||||
'login.signingIn': 'Signing in...',
|
||||
'loading.text': 'Loading...',
|
||||
'sidebar.serverPacks': 'Server Packs', 'sidebar.localPacks': 'Local Packs',
|
||||
@@ -171,6 +172,7 @@ const LOCALES = {
|
||||
'nav.packs': 'Сборки', 'nav.news': 'Новости', 'nav.settings': 'Настройки',
|
||||
'login.title': 'Вход', 'login.username': 'Логин', 'login.password': 'Пароль',
|
||||
'login.hint': 'Новый аккаунт создаётся автоматически при первом входе',
|
||||
'login.register': 'Нет аккаунта? Зарегистрироваться',
|
||||
'login.signingIn': 'Вход...',
|
||||
'loading.text': 'Загрузка...',
|
||||
'sidebar.serverPacks': 'Серверные сборки', 'sidebar.localPacks': 'Локальные сборки',
|
||||
@@ -374,6 +376,11 @@ class ZernMCLauncher {
|
||||
this._playtimeAccumulated = 0;
|
||||
this._playtimeStartTime = null;
|
||||
this._currentPlaytimePack = null;
|
||||
this._ws = null;
|
||||
this._wsReconnectTimer = null;
|
||||
this._wsFallbackPolling = false;
|
||||
this._wsStatusTimer = null;
|
||||
this._adminClientsTimer = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -383,6 +390,142 @@ class ZernMCLauncher {
|
||||
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) {
|
||||
_currentLocale = lang;
|
||||
_localeData = LOCALES[lang] || LOCALES.en;
|
||||
@@ -396,6 +539,9 @@ class ZernMCLauncher {
|
||||
const c = document.getElementById('bg-canvas');
|
||||
const ctx = c.getContext('2d');
|
||||
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 draw = () => {
|
||||
@@ -410,15 +556,23 @@ class ZernMCLauncher {
|
||||
window.addEventListener('mousemove', e => {
|
||||
mx = (e.clientX / innerWidth - 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;
|
||||
oy += (my * 0.3 - oy) * 0.04;
|
||||
draw();
|
||||
requestAnimationFrame(anim);
|
||||
};
|
||||
resize();
|
||||
anim();
|
||||
requestAnimationFrame(anim);
|
||||
}
|
||||
|
||||
// ==================== API ====================
|
||||
@@ -489,7 +643,6 @@ class ZernMCLauncher {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
console.log('handleLogin: attempting login for', username);
|
||||
const errEl = document.getElementById('login-error');
|
||||
const btn = document.getElementById('login-btn');
|
||||
const label = btn.querySelector('.btn-label');
|
||||
@@ -497,6 +650,26 @@ class ZernMCLauncher {
|
||||
|
||||
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;
|
||||
label.textContent = t('login.signingIn');
|
||||
spinner.classList.remove('hidden');
|
||||
@@ -534,9 +707,30 @@ class ZernMCLauncher {
|
||||
showLogin() {
|
||||
document.getElementById('login-screen').classList.remove('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() {
|
||||
this.closeWebSocket();
|
||||
this.state.selectedPack = null;
|
||||
this.state.instances = [];
|
||||
this.state.account = null;
|
||||
@@ -561,6 +755,7 @@ class ZernMCLauncher {
|
||||
this.loadNews();
|
||||
this.loadFriends();
|
||||
this.loadPlaytimeStats();
|
||||
this.initWebSocket();
|
||||
this.startFriendStatusHeartbeat();
|
||||
this.enhanceSelects();
|
||||
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) {
|
||||
document.querySelectorAll('.admin-tab').forEach(function(t) {
|
||||
t.classList.toggle('active', t.dataset.atab === tab);
|
||||
@@ -960,6 +1169,7 @@ class ZernMCLauncher {
|
||||
document.querySelectorAll('.admin-tab-content').forEach(function(c) {
|
||||
c.classList.toggle('active', c.id === 'atab-' + tab);
|
||||
});
|
||||
this.adminLoadDashboard();
|
||||
if (tab === 'clients') this.adminLoadClients();
|
||||
if (tab === 'mods') this.adminLoadMods();
|
||||
if (tab === 'users') this.adminClearUser();
|
||||
@@ -968,17 +1178,10 @@ class ZernMCLauncher {
|
||||
}
|
||||
|
||||
// --- Clients ---
|
||||
async adminLoadClients() {
|
||||
_renderAdminClients(clients) {
|
||||
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) {
|
||||
if (!clients || !clients.length) {
|
||||
el.innerHTML = '<div class="admin-empty">' + t('admin.noClients') + '</div>';
|
||||
return;
|
||||
}
|
||||
@@ -990,7 +1193,26 @@ class ZernMCLauncher {
|
||||
+ '</div>';
|
||||
});
|
||||
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 ---
|
||||
@@ -1153,14 +1375,93 @@ class ZernMCLauncher {
|
||||
}
|
||||
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>';
|
||||
var isActive = p.is_active !== false;
|
||||
html += '<div class="admin-item" style="opacity:' + (isActive ? '1' : '0.4') + '">'
|
||||
+ '<span class="admin-item-name" style="font-family:var(--mono);font-size:12px">' + app.esc(p.code || '?') + '</span>'
|
||||
+ '<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;
|
||||
}
|
||||
|
||||
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 ---
|
||||
async adminLoadNewsList() {
|
||||
var el = document.getElementById('admin-news-list');
|
||||
@@ -1209,6 +1510,7 @@ class ZernMCLauncher {
|
||||
document.getElementById('admin-news-title').value = '';
|
||||
document.getElementById('admin-news-version').value = '';
|
||||
document.getElementById('admin-news-body').value = '';
|
||||
document.getElementById('admin-news-preview').innerHTML = 'Preview will appear here...';
|
||||
this.adminLoadNewsList();
|
||||
} else {
|
||||
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) {
|
||||
var r = await this.req('/admin/news/delete', {
|
||||
method: 'POST',
|
||||
@@ -1584,7 +1897,6 @@ class ZernMCLauncher {
|
||||
async installZernMCPack() {
|
||||
const packName = document.getElementById('zernmc-pack-select').value;
|
||||
if (!packName) { this.toast(t('select.selectPack'), 'error'); return; }
|
||||
// Use server pack name as instance name (no local naming)
|
||||
const instanceName = packName;
|
||||
|
||||
const r = await this.req('/install', {
|
||||
@@ -1595,7 +1907,9 @@ class ZernMCLauncher {
|
||||
this.toast(t('toast.installing'), 'info');
|
||||
this.startProgressPoll();
|
||||
} 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) {
|
||||
const p = document.getElementById('install-progress');
|
||||
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...';
|
||||
const stageInfo = document.getElementById('progress-stage');
|
||||
if (r.data.stageName && r.data.stageCount > 1) {
|
||||
@@ -1638,10 +1953,15 @@ class ZernMCLauncher {
|
||||
if (!r.data.inProgress) {
|
||||
this.stopProgressPoll();
|
||||
this.hideInstallModal();
|
||||
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && r.data.label.startsWith('Error'));
|
||||
this.toast(failed ? (r.data.label || t('toast.installFailed')) : t('toast.installComplete'), failed ? 'error' : 'success');
|
||||
var failed = r.data.stageName === 'Failed' || r.data.stageName === 'Error' || (r.data.label && (r.data.label.startsWith('Error') || r.data.label.startsWith('Installation failed')));
|
||||
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();
|
||||
}
|
||||
} else if (!r.success) {
|
||||
this.stopProgressPoll();
|
||||
this.toast(r.error || t('toast.installFailed'), 'error');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
@@ -1701,10 +2021,23 @@ class ZernMCLauncher {
|
||||
if (!el) return;
|
||||
var cores = data.cpuCores || '-';
|
||||
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');
|
||||
if (data.systemBasedJvm) {
|
||||
flags += ' | ' + os;
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
const ram = document.getElementById('ram-slider').value;
|
||||
@@ -1747,13 +2080,18 @@ class ZernMCLauncher {
|
||||
}
|
||||
|
||||
startFriendStatusHeartbeat() {
|
||||
if (this._friendsPollInterval) clearInterval(this._friendsPollInterval);
|
||||
this._friendsPollInterval = setInterval(this.loadFriends.bind(this), 10000);
|
||||
// Send online status
|
||||
this.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: this.state.selectedPack ? this.state.selectedPack.name : null }) });
|
||||
setInterval(function() {
|
||||
app.req('/friends/status', { method: 'POST', body: JSON.stringify({ online: true, current_pack: app.state.selectedPack ? app.state.selectedPack.name : null }) });
|
||||
}, 60000);
|
||||
// Send online status via WebSocket if connected, fallback to REST
|
||||
var sendStatus = function() {
|
||||
var pack = app.state.selectedPack ? app.state.selectedPack.name : null;
|
||||
if (app._ws && app._ws.readyState === WebSocket.OPEN) {
|
||||
app._sendWs({ type: 'status', current_pack: pack || '' });
|
||||
} else {
|
||||
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() {
|
||||
|
||||
@@ -176,11 +176,18 @@ body {
|
||||
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 {
|
||||
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-name { font-size: 16px; font-weight: 700; }
|
||||
@@ -188,7 +195,6 @@ body {
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
padding-bottom: 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar-nav-primary, .sidebar-nav-secondary {
|
||||
display: flex; gap: 4px;
|
||||
@@ -214,8 +220,9 @@ body {
|
||||
|
||||
.pack-list {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
overflow-y: auto; max-height: calc((100vh - 460px) / 2);
|
||||
min-height: 40px;
|
||||
overflow-y: auto;
|
||||
max-height: min(calc(50vh - 80px), 280px);
|
||||
min-height: 60px;
|
||||
}
|
||||
.pack-list:empty::after {
|
||||
content: 'No packs'; display: block; padding: 12px 8px;
|
||||
@@ -291,8 +298,17 @@ body {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.view { display: none; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.view.active { display: flex; }
|
||||
.view {
|
||||
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 {
|
||||
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-title { font-size: 13px; font-weight: 500; }
|
||||
.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
@@ -68,36 +68,11 @@
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.15.1</version>
|
||||
</dependency>
|
||||
<!-- JavaFX for Windows -->
|
||||
<!-- JCEF (Chromium Embedded Framework) -->
|
||||
<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>
|
||||
<groupId>me.friwi</groupId>
|
||||
<artifactId>jcefmaven</artifactId>
|
||||
<version>146.0.10</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
@@ -156,6 +131,51 @@
|
||||
</properties>
|
||||
</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 ==================== -->
|
||||
<profile>
|
||||
<id>zernmc</id>
|
||||
|
||||
+216
-7
@@ -43,6 +43,32 @@ class BanUserRequest(BaseModel):
|
||||
days: int = Field(..., ge=1, le=365)
|
||||
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")
|
||||
@@ -532,11 +558,17 @@ async def get_admin_stats(
|
||||
recent_registrations = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
||||
""", (week_ago,)).fetchone()["count"]
|
||||
|
||||
|
||||
# Онлайн пользователи
|
||||
online_now = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM user_status WHERE is_online = 1
|
||||
""").fetchone()["count"]
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_passes": active_passes,
|
||||
"banned_users": banned_users,
|
||||
"online_now": online_now,
|
||||
"recent_registrations_7d": recent_registrations,
|
||||
"roles_distribution": [
|
||||
{"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_DIR = Path(__file__).parent / "whitelist"
|
||||
@@ -656,15 +767,21 @@ async def remove_whitelist_mod(
|
||||
|
||||
@router.get("/clients")
|
||||
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:
|
||||
# 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("""
|
||||
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
|
||||
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
|
||||
JOIN user_status us ON u.id = us.user_id
|
||||
WHERE us.is_online = 1
|
||||
GROUP BY u.id
|
||||
ORDER BY us.last_seen DESC
|
||||
""").fetchall()
|
||||
clients = []
|
||||
@@ -672,7 +789,7 @@ async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODE
|
||||
clients.append({
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"online": bool(row["is_online"]),
|
||||
"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 ""
|
||||
@@ -811,6 +928,98 @@ async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODER
|
||||
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")
|
||||
async def admin_delete_whitelist_mod_adapter(
|
||||
mod_hash: str,
|
||||
|
||||
@@ -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 = ?",
|
||||
(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)
|
||||
|
||||
|
||||
@@ -116,7 +116,12 @@ async def remove_friend(
|
||||
@router.get("/friends/list")
|
||||
async def list_friends(current_user: dict = Depends(get_current_user)):
|
||||
friends = []
|
||||
stale_cutoff = time.time() - 300
|
||||
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("""
|
||||
SELECT u.id, u.username, u.role,
|
||||
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}
|
||||
|
||||
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")
|
||||
async def update_status(
|
||||
req: StatusUpdateRequest,
|
||||
@@ -173,4 +189,11 @@ async def update_status(
|
||||
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
|
||||
last_seen = CURRENT_TIMESTAMP
|
||||
""", (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"}
|
||||
|
||||
+154
-2
@@ -11,7 +11,7 @@ import httpx
|
||||
import json
|
||||
import structlog
|
||||
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 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 roles import Permissions, has_permission
|
||||
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 ws_manager import manager
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
@@ -222,8 +223,14 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
asyncio.create_task(periodic_sync())
|
||||
|
||||
# Start WebSocket admin clients broadcaster
|
||||
broadcaster = asyncio.create_task(admin_clients_broadcaster())
|
||||
|
||||
yield
|
||||
|
||||
# Cancel WebSocket broadcaster
|
||||
broadcaster.cancel()
|
||||
|
||||
# Cleanup proxy client
|
||||
if proxy_client:
|
||||
await proxy_client.aclose()
|
||||
@@ -925,6 +932,151 @@ app.include_router(friends_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
|
||||
original_data_received = HttpToolsProtocol.data_received
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user