diff --git a/launcher/dependency-reduced-pom.xml b/launcher/dependency-reduced-pom.xml
index 81984aa..24abee4 100644
--- a/launcher/dependency-reduced-pom.xml
+++ b/launcher/dependency-reduced-pom.xml
@@ -3,7 +3,7 @@
4.0.0
me.sashegdev
ZernMCLauncher
- 1.0.5
+ 1.0.6
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java
index a5bae5c..fb73deb 100644
--- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/Main.java
@@ -1,5 +1,6 @@
package me.sashegdev.zernmc.launcher;
+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.utils.*;
@@ -17,8 +18,12 @@ public class Main {
private static final String CURRENT_VERSION = Version.getCurrentVersion();
- public static void main(String[] args) {
+ public static void main(String[] args) throws IOException {
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
+ System.setProperty("file.encoding", "UTF-8");
+ System.setProperty("sun.err.encoding", "UTF-8");
+ System.setProperty("sun.stdout.encoding", "UTF-8");
+ java.nio.charset.Charset.defaultCharset();
ZAnsi.install();
System.out.print("\033[H\033[2J");
@@ -29,6 +34,23 @@ public class Main {
checkAndAutoUpdateLauncher();
+ // === АВТОРИЗАЦИЯ ===
+ System.out.println(ZAnsi.cyan("Проверка авторизации..."));
+ boolean sessionRestored = AuthManager.loadSavedSession();
+
+ if (!sessionRestored) {
+ LoginMenu loginMenu = new LoginMenu();
+ boolean loggedIn = loginMenu.show();
+ if (!loggedIn) {
+ System.out.println(ZAnsi.yellow("До свидания!"));
+ ZAnsi.uninstall();
+ System.exit(0);
+ }
+ } else {
+ System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
+ }
+ // === КОНЕЦ АВТОРИЗАЦИИ ===
+
try {
mainLoop();
} catch (Exception e) {
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java
new file mode 100644
index 0000000..6fd7fea
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/auth/AuthManager.java
@@ -0,0 +1,206 @@
+package me.sashegdev.zernmc.launcher.auth;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.annotations.SerializedName;
+import me.sashegdev.zernmc.launcher.utils.Config;
+import me.sashegdev.zernmc.launcher.utils.ZAnsi;
+import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+
+public class AuthManager {
+
+ private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(15))
+ .build();
+
+ private static AuthSession session = null;
+
+ public static boolean loadSavedSession() {
+ if (!Files.exists(AUTH_FILE)) return false;
+ try {
+ String json = Files.readString(AUTH_FILE);
+ AuthSession loaded = GSON.fromJson(json, AuthSession.class);
+ if (loaded == null || loaded.accessToken == null) return false;
+
+ session = loaded;
+ if (isAccessTokenExpired()) {
+ return tryRefresh();
+ }
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ public static AuthResult login(String username, String password) {
+ return authRequest("/auth/login", username, password);
+ }
+
+ public static AuthResult register(String username, String password) {
+ return authRequest("/auth/register", username, password);
+ }
+
+ private static AuthResult authRequest(String endpoint, String username, String password) {
+ try {
+ String body = GSON.toJson(new LoginRequest(username, password));
+ HttpResponse resp = post(endpoint, body);
+
+ if (resp.statusCode() == 200) {
+ session = GSON.fromJson(resp.body(), AuthSession.class);
+ session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
+ saveSession();
+ return AuthResult.ok();
+ } else {
+ return AuthResult.fail(extractError(resp.body()));
+ }
+ } catch (Exception e) {
+ return AuthResult.fail("Ошибка соединения: " + e.getMessage());
+ }
+ }
+
+ public static void logout() {
+ if (session != null && session.refreshToken != null) {
+ try { post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}"); }
+ catch (Exception ignored) {}
+ }
+ session = null;
+ try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
+ }
+
+ public static boolean isLoggedIn() {
+ return session != null && session.accessToken != null;
+ }
+
+ public static String getUsername() {
+ return session != null ? session.username : "Player";
+ }
+
+ public static String getUuid() {
+ return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
+ }
+
+ public static String getAccessToken() {
+ if (session == null) return "0";
+ if (isAccessTokenExpired()) tryRefresh();
+ return session.accessToken != null ? session.accessToken : "0";
+ }
+
+ private static boolean isAccessTokenExpired() {
+ if (session == null) return true;
+ return System.currentTimeMillis() / 1000L >= session.expiresAt - 300;
+ }
+
+ private static boolean tryRefresh() {
+ if (session == null || session.refreshToken == null) return false;
+ try {
+ String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
+ HttpResponse resp = post("/auth/refresh", body);
+
+ if (resp.statusCode() == 200) {
+ AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
+ newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
+ session = newSession;
+ saveSession();
+ return true;
+ }
+ } catch (Exception ignored) {}
+ session = null;
+ try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
+ return false;
+ }
+
+ private static void saveSession() {
+ try {
+ Files.createDirectories(AUTH_FILE.getParent());
+ Files.writeString(AUTH_FILE, GSON.toJson(session));
+ } catch (IOException e) {
+ System.err.println(ZAnsi.yellow("Не удалось сохранить сессию: " + e.getMessage()));
+ }
+ }
+
+ private static HttpResponse post(String endpoint, String jsonBody) throws Exception {
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(ZHttpClient.getBaseUrl() + endpoint))
+ .header("Content-Type", "application/json")
+ .timeout(Duration.ofSeconds(15))
+ .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
+ .build();
+ return HTTP.send(req, HttpResponse.BodyHandlers.ofString());
+ }
+
+ private static String extractError(String body) {
+ try {
+ int idx = body.indexOf("\"detail\"");
+ if (idx != -1) {
+ int s = body.indexOf("\"", idx + 9) + 1;
+ int e = body.indexOf("\"", s);
+ if (e > s) return body.substring(s, e);
+ }
+ } catch (Exception ignored) {}
+ return "Неизвестная ошибка";
+ }
+
+ // ====================== ВНУТРЕННИЕ КЛАССЫ ======================
+
+ public static class AuthSession {
+ @SerializedName("access_token") public String accessToken;
+ @SerializedName("refresh_token") public String refreshToken;
+ @SerializedName("expires_in") public int expiresIn;
+ public long expiresAt;
+ public String username;
+ public String uuid;
+ }
+
+ private static class LoginRequest {
+ final String username;
+ final String password;
+ LoginRequest(String u, String p) { this.username = u; this.password = p; }
+ }
+
+ public static class AuthResult {
+ public final boolean success;
+ public final String error;
+ private AuthResult(boolean s, String e) { success = s; error = e; }
+ public static AuthResult ok() { return new AuthResult(true, null); }
+ public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
+ }
+
+ public static boolean hasActivePass() {
+ if (!isLoggedIn()) return false;
+ try {
+ String response = ZHttpClient.get("/auth/pass/my");
+ // Простая проверка — есть ли хотя бы одна активная проходка
+ return response.contains("\"is_active\":true");
+ } catch (Exception e) {
+ System.err.println("Не удалось проверить проходки: " + e.getMessage());
+ return false;
+ }
+ }
+
+ public static String activatePass(String passCode) {
+ try {
+ String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}";
+ HttpResponse resp = post("/auth/pass/activate", json);
+
+ if (resp.statusCode() == 200) {
+ return "Проходка успешно активирована!";
+ } else {
+ String error = extractError(resp.body());
+ return "Ошибка: " + error;
+ }
+ } catch (Exception e) {
+ return "Ошибка соединения: " + e.getMessage();
+ }
+ }
+}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java
index 4419d62..8c22af4 100644
--- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LaunchMenu.java
@@ -1,5 +1,6 @@
package me.sashegdev.zernmc.launcher.menu;
+import me.sashegdev.zernmc.launcher.auth.AuthManager;
import me.sashegdev.zernmc.launcher.minecraft.Instance;
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
@@ -80,6 +81,32 @@ public class LaunchMenu {
}
private void installServerPack() throws Exception {
+ if (!AuthManager.hasActivePass()) {
+ ConsoleUtils.clearScreen();
+ System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
+ System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку."));
+ System.out.println();
+ System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: "));
+
+ String code = Input.readLine();
+ if (code.isEmpty()) return;
+
+ String result = AuthManager.activatePass(code);
+ System.out.println(ZAnsi.cyan(result));
+
+ if (!result.contains("успешно")) {
+ ConsoleUtils.pause();
+ return;
+ }
+
+ // Повторная проверка
+ if (!AuthManager.hasActivePass()) {
+ System.out.println(ZAnsi.brightRed("Не удалось активировать проходку."));
+ ConsoleUtils.pause();
+ return;
+ }
+ }
+
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера..."));
@@ -502,11 +529,22 @@ public class LaunchMenu {
}
private void launchExistingInstance(Instance instance) {
+ if (instance.isServerPack() && !AuthManager.hasActivePass()) {
+ ConsoleUtils.clearScreen();
+ System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
+ ConsoleUtils.pause();
+ return;
+ }
ConsoleUtils.clearScreen();
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
MinecraftLib lib = new MinecraftLib(instance);
- LaunchOptions options = new LaunchOptions();
+ LaunchOptions options = new LaunchOptions();
+
+ // Авторизация Minecraft
+ options.setUsername(AuthManager.getUsername());
+ options.setUuid(AuthManager.getUuid());
+ options.setAccessToken(AuthManager.getAccessToken());
try {
lib.launch(options);
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java
new file mode 100644
index 0000000..d58f50f
--- /dev/null
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/menu/LoginMenu.java
@@ -0,0 +1,167 @@
+package me.sashegdev.zernmc.launcher.menu;
+
+import me.sashegdev.zernmc.launcher.auth.AuthManager;
+import me.sashegdev.zernmc.launcher.auth.AuthManager.AuthResult;
+import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
+import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
+import me.sashegdev.zernmc.launcher.utils.Input;
+import me.sashegdev.zernmc.launcher.utils.ZAnsi;
+
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Экран входа/регистрации.
+ * Показывается при старте лаунчера, если нет сохранённой сессии.
+ *
+ * show() возвращает true — пользователь вошёл/зарегистрировался
+ * false — пользователь выбрал выход из лаунчера
+ */
+public class LoginMenu {
+
+ /**
+ * Главный экран выбора действия.
+ */
+ public boolean show() throws IOException {
+ while (true) {
+ ConsoleUtils.clearScreen();
+ printBanner();
+
+ List options = List.of(
+ "Войти в аккаунт",
+ "Создать аккаунт",
+ "Выйти из лаунчера"
+ );
+
+ ArrowMenu menu = new ArrowMenu("Добро пожаловать в ZernMC!", options);
+ int choice = menu.show();
+
+ if (choice == -1 || choice == 2) return false;
+
+ boolean success = switch (choice) {
+ case 0 -> doLogin();
+ case 1 -> doRegister();
+ default -> false;
+ };
+
+ if (success) return true;
+ // Если не успех — покажем меню снова (ошибка уже напечатана внутри методов)
+ }
+ }
+
+ /**
+ * Показывается когда пользователь уже вошёл — предлагает выйти из аккаунта.
+ */
+ public void showAccountMenu() throws IOException {
+ ConsoleUtils.clearScreen();
+
+ System.out.println(ZAnsi.header("=== Аккаунт ==="));
+ System.out.println();
+ System.out.println(ZAnsi.white(" Игрок: ") + ZAnsi.brightGreen(AuthManager.getUsername()));
+ System.out.println(ZAnsi.white(" UUID: ") + ZAnsi.cyan(AuthManager.getUuid()));
+ System.out.println();
+
+ List options = List.of(
+ "Выйти из аккаунта",
+ "Назад"
+ );
+
+ ArrowMenu menu = new ArrowMenu("Управление аккаунтом", options);
+ int choice = menu.show();
+
+ if (choice == 0) {
+ AuthManager.logout();
+ System.out.println(ZAnsi.yellow("Вы вышли из аккаунта."));
+ ConsoleUtils.pause();
+ }
+ }
+
+ // ====================== ПРИВАТНЫЕ МЕТОДЫ ======================
+
+ private boolean doLogin() throws IOException {
+ ConsoleUtils.clearScreen();
+ printBanner();
+ System.out.println(ZAnsi.cyan(" [ Вход в аккаунт ]"));
+ System.out.println();
+
+ String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
+ if (username.isEmpty()) return false;
+
+ String password = readPassword(" Пароль: ");
+ if (password.isEmpty()) return false;
+
+ System.out.println();
+ System.out.print(ZAnsi.cyan(" Выполняем вход..."));
+
+ AuthResult result = AuthManager.login(username, password);
+
+ if (result.success) {
+ System.out.println("\r" + ZAnsi.brightGreen(" Добро пожаловать, " + AuthManager.getUsername() + "! "));
+ ConsoleUtils.pause();
+ return true;
+ } else {
+ System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
+ ConsoleUtils.pause();
+ return false;
+ }
+ }
+
+ private boolean doRegister() throws IOException {
+ ConsoleUtils.clearScreen();
+ printBanner();
+ System.out.println(ZAnsi.cyan(" [ Создание аккаунта ]"));
+ System.out.println();
+ System.out.println(ZAnsi.yellow(" Допустимые символы в имени: a-z, A-Z, 0-9, _"));
+ System.out.println(ZAnsi.yellow(" Длина имени: 3–16 символов | Длина пароля: от 6 символов"));
+ System.out.println();
+
+ String username = Input.readLine(ZAnsi.white(" Имя пользователя: "));
+ if (username.isEmpty()) return false;
+
+ String password = readPassword(" Пароль: ");
+ if (password.isEmpty()) return false;
+
+ String confirm = readPassword(" Повторите пароль: ");
+ if (!password.equals(confirm)) {
+ System.out.println(ZAnsi.brightRed("\n Пароли не совпадают!"));
+ ConsoleUtils.pause();
+ return false;
+ }
+
+ System.out.println();
+ System.out.print(ZAnsi.cyan(" Создаём аккаунт..."));
+
+ AuthResult result = AuthManager.register(username, password);
+
+ if (result.success) {
+ System.out.println("\r" + ZAnsi.brightGreen(" Аккаунт создан! Добро пожаловать, " + AuthManager.getUsername() + "! "));
+ ConsoleUtils.pause();
+ return true;
+ } else {
+ System.out.println("\r" + ZAnsi.brightRed(" Ошибка: " + result.error + " "));
+ ConsoleUtils.pause();
+ return false;
+ }
+ }
+
+ /**
+ * Читаем пароль — стараемся скрыть вывод через Console,
+ * если недоступно (IDE/терминал без TTY) — читаем обычным способом.
+ */
+ private String readPassword(String prompt) {
+ java.io.Console console = System.console();
+ if (console != null) {
+ char[] chars = console.readPassword(prompt);
+ return chars != null ? new String(chars) : "";
+ }
+ // Fallback: в IDE пароль будет виден
+ return Input.readLine(prompt);
+ }
+
+ private void printBanner() {
+ System.out.println(ZAnsi.header("╔══════════════════════════════╗"));
+ System.out.println(ZAnsi.header("║ ZernMC Launcher ║"));
+ System.out.println(ZAnsi.header("╚══════════════════════════════╝"));
+ System.out.println();
+ }
+}
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java
index 9c31311..942fc16 100644
--- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Config.java
@@ -113,6 +113,10 @@ public class Config {
return CONFIG_DIR.resolve("jre");
}
+ public static Path getConfigDir() {
+ return CONFIG_DIR;
+ }
+
/**
* Полезная информация для пользователя
*/
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java
index ecfb442..7ece5e6 100644
--- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/Input.java
@@ -1,10 +1,18 @@
package me.sashegdev.zernmc.launcher.utils;
+import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
+
+import java.io.IOException;
+import java.util.List;
import java.util.Scanner;
+/**
+ * Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
+ */
public class Input {
- private static final Scanner scanner = new Scanner(System.in);
+ // Используем UTF-8 явно — это помогает на Windows
+ private static final Scanner scanner = new Scanner(System.in, "UTF-8");
public static String readLine() {
return scanner.nextLine().trim();
@@ -36,9 +44,39 @@ public class Input {
}
}
- public static boolean confirm(String prompt) {
- System.out.print(prompt + " (да/нет): ");
- String answer = scanner.nextLine().trim().toLowerCase();
- return answer.equals("да") || answer.equals("y") || answer.equals("yes");
+ /**
+ * Новый confirm через ArrowMenu
+ * @throws IOException
+ */
+ public static boolean confirm(String question) throws IOException {
+ ConsoleUtils.clearScreen(); // опционально, можно убрать
+
+ List options = List.of(
+ "Да",
+ "Нет"
+ );
+
+ ArrowMenu menu = new ArrowMenu(question, options);
+ int choice = menu.show();
+
+ return choice == 0; // 0 = "Да"
+ }
+
+ /**
+ * Альтернативный confirm без очистки экрана
+ * @throws IOException
+ */
+ public static boolean confirmInline(String question) throws IOException {
+ List options = List.of("Да", "Нет");
+ ArrowMenu menu = new ArrowMenu(question, options);
+ int choice = menu.show();
+ return choice == 0;
+ }
+
+ /**
+ * Закрытие сканнера (вызывать при выходе из программы, если нужно)
+ */
+ public static void close() {
+ scanner.close();
}
}
\ No newline at end of file
diff --git a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
index a21e01d..3e0bff0 100644
--- a/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
+++ b/launcher/src/main/java/me/sashegdev/zernmc/launcher/utils/ZHttpClient.java
@@ -26,14 +26,14 @@ public class ZHttpClient {
.build();
private static final String BASE_URL = "http://87.120.187.36:1582";
-
+
// Глобальный прокси режим (для обратной совместимости)
private static final AtomicBoolean useProxyMode = new AtomicBoolean(false);
private static final AtomicBoolean proxyTested = new AtomicBoolean(false);
-
+
// Умное проксирование по сервисам
public enum ServiceType {
- ZERN_SERVER(BASE_URL, true), // Всегда прямое подключение
+ ZERN_SERVER(BASE_URL, true),
FABRIC_META("https://meta.fabricmc.net", false),
FABRIC_MAVEN("https://maven.fabricmc.net", false),
MOJANG_META("https://piston-meta.mojang.com", false),
@@ -41,39 +41,34 @@ public class ZHttpClient {
FORGE_MAVEN("https://maven.minecraftforge.net", false),
GOOGLE("https://google.com", false),
CLOUDFLARE("https://cloudflare.com", false);
-
+
private final String baseUrl;
private final boolean alwaysDirect;
-
+
ServiceType(String baseUrl, boolean alwaysDirect) {
this.baseUrl = baseUrl;
this.alwaysDirect = alwaysDirect;
}
-
- public String getBaseUrl() {
- return baseUrl;
- }
-
- public boolean isAlwaysDirect() {
- return alwaysDirect;
- }
+
+ public String getBaseUrl() { return baseUrl; }
+ public boolean isAlwaysDirect() { return alwaysDirect; }
}
-
+
// Статусы сервисов
private static final Map serviceProxyMode = new ConcurrentHashMap<>();
private static final Map serviceFailCount = new ConcurrentHashMap<>();
private static final Map serviceLastCheckTime = new ConcurrentHashMap<>();
private static final Map serviceHealthy = new ConcurrentHashMap<>();
-
+
private static final int MAX_FAILS_BEFORE_PROXY = 2;
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута
- private static final long CHECK_TIMEOUT_MS = 5000; // 5 секунд на проверку
-
+ private static final long CHECK_TIMEOUT_MS = 7000; // 7 секунд на проверку
+
// Статистика
private static int directSuccessCount = 0;
private static int proxySuccessCount = 0;
private static int directFailCount = 0;
-
+
static {
for (ServiceType type : ServiceType.values()) {
serviceProxyMode.put(type, false);
@@ -81,34 +76,33 @@ public class ZHttpClient {
serviceHealthy.put(type, false);
}
}
-
+
/**
- * Проверить все сервисы при старте
+ * Вызывать один раз при запуске лаунчера
*/
public static void checkAllServicesOnStartup() {
if (proxyTested.get()) return;
-
+
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
-
+
List servicesToCheck = List.of(
- ServiceType.ZERN_SERVER,
- ServiceType.GOOGLE,
- ServiceType.FABRIC_META,
- ServiceType.FABRIC_MAVEN,
- ServiceType.MOJANG_META,
- ServiceType.FORGE_MAVEN
+ ServiceType.ZERN_SERVER,
+ ServiceType.GOOGLE,
+ ServiceType.FABRIC_META,
+ ServiceType.FABRIC_MAVEN,
+ ServiceType.MOJANG_META,
+ ServiceType.MOJANG_RESOURCES,
+ ServiceType.FORGE_MAVEN
);
-
+
for (ServiceType service : servicesToCheck) {
boolean isHealthy = checkServiceHealth(service);
serviceHealthy.put(service, isHealthy);
-
+
if (service.isAlwaysDirect()) {
- if (!isHealthy) {
- System.out.println(ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
- } else {
- System.out.println(ZAnsi.green(" " + service.name() + " - OK"));
- }
+ System.out.println(isHealthy ?
+ ZAnsi.green(" " + service.name() + " - OK") :
+ ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
} else {
if (isHealthy) {
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
@@ -119,52 +113,70 @@ public class ZHttpClient {
}
}
}
-
- // Проверяем, нужно ли включить глобальный прокси режим
- boolean anyCriticalDown = !serviceHealthy.get(ServiceType.ZERN_SERVER);
- if (anyCriticalDown) {
+
+ if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
}
-
+
proxyTested.set(true);
-
- // Запускаем фоновую проверку
startHealthCheckThread();
-
printStats();
}
-
+
/**
- * Проверить здоровье конкретного сервиса
+ * Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
*/
- private static boolean checkServiceHealth(ServiceType service) {
- if (service.isAlwaysDirect()) {
- return checkDirectConnection(service.getBaseUrl());
+ public static void forceCheckMojangServices() {
+ System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов..."));
+
+ for (ServiceType service : List.of(ServiceType.MOJANG_META, ServiceType.MOJANG_RESOURCES)) {
+ boolean healthy = checkServiceHealth(service);
+ serviceHealthy.put(service, healthy);
+
+ if (healthy) {
+ System.out.println(ZAnsi.green(" " + service.name() + " доступен напрямую"));
+ serviceProxyMode.put(service, false);
+ serviceFailCount.put(service, 0);
+ } else {
+ System.out.println(ZAnsi.yellow(" " + service.name() + " недоступен → прокси режим активирован"));
+ serviceProxyMode.put(service, true);
+ serviceFailCount.put(service, MAX_FAILS_BEFORE_PROXY);
+ }
}
-
+ }
+
+ private static boolean checkServiceHealth(ServiceType service) {
return checkDirectConnection(service.getBaseUrl());
}
-
+
/**
- * Проверить прямое подключение к URL
+ * Улучшенная проверка прямого подключения
*/
- private static boolean checkDirectConnection(String url) {
+ private static boolean checkDirectConnection(String baseUrl) {
+ String testUrl = baseUrl;
+
+ if (baseUrl.contains("piston-meta.mojang.com") || baseUrl.contains("launchermeta.mojang.com")) {
+ testUrl = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
+ } else if (baseUrl.contains("resources.download.minecraft.net")) {
+ testUrl = "https://resources.download.minecraft.net/00/0000000000000000000000000000000000000000";
+ }
+
try {
HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
+ .uri(URI.create(testUrl))
.timeout(Duration.ofMillis(CHECK_TIMEOUT_MS))
- .HEAD()
+ .GET()
+ .header("User-Agent", "ZernMC-Launcher/HealthCheck")
.build();
- HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding());
- return response.statusCode() < 500;
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+ int code = response.statusCode();
+ return code == 200 || code == 404; // 404 для ресурсов — нормально
} catch (Exception e) {
return false;
}
}
-
- /**
- * Запустить фоновый поток для периодической проверки
- */
+
private static void startHealthCheckThread() {
Thread healthThread = new Thread(() -> {
while (true) {
@@ -179,30 +191,23 @@ public class ZHttpClient {
healthThread.setDaemon(true);
healthThread.start();
}
-
- /**
- * Периодическая проверка здоровья сервисов
- */
+
private static void performHealthCheck() {
for (ServiceType service : ServiceType.values()) {
if (service.isAlwaysDirect()) continue;
-
+
boolean isHealthy = checkServiceHealth(service);
serviceHealthy.put(service, isHealthy);
-
+
if (isHealthy && serviceProxyMode.get(service)) {
- // Сервис восстановился - пробуем переключить обратно
- Long lastCheck = serviceLastCheckTime.get(service);
- if (lastCheck == null || System.currentTimeMillis() - lastCheck > HEALTH_CHECK_INTERVAL_MS) {
- serviceProxyMode.put(service, false);
- serviceFailCount.put(service, 0);
- System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение"));
- }
+ serviceProxyMode.put(service, false);
+ serviceFailCount.put(service, 0);
+ System.out.println(ZAnsi.green("[NET] " + service.name() + " восстановлен, переключен на прямое подключение"));
} else if (!isHealthy && !serviceProxyMode.get(service)) {
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
serviceFailCount.put(service, fails);
serviceLastCheckTime.put(service, System.currentTimeMillis());
-
+
if (fails >= MAX_FAILS_BEFORE_PROXY) {
serviceProxyMode.put(service, true);
System.out.println(ZAnsi.yellow("[NET] " + service.name() + " недоступен, включен прокси режим"));
@@ -210,14 +215,11 @@ public class ZHttpClient {
}
}
}
-
- /**
- * Определить тип сервиса по URL
- */
+
private static ServiceType detectService(String url) {
if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META;
if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN;
- if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com"))
+ if (url.contains("piston-meta.mojang.com") || url.contains("launchermeta.mojang.com"))
return ServiceType.MOJANG_META;
if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
if (url.contains("maven.minecraftforge.net")) return ServiceType.FORGE_MAVEN;
@@ -225,108 +227,158 @@ public class ZHttpClient {
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
return null;
}
-
- /**
- * Нужно ли использовать прокси для URL
- */
+
private static boolean shouldUseProxyForUrl(String url) {
if (useProxyMode.get()) return true;
-
+
ServiceType service = detectService(url);
- if (service == null) return false;
- if (service.isAlwaysDirect()) return false;
-
+ if (service == null || service.isAlwaysDirect()) return false;
+
return serviceProxyMode.getOrDefault(service, false);
}
-
+
+ private static boolean isConnectionError(Throwable e) {
+ Throwable cause = e.getCause() != null ? e.getCause() : e;
+ String msg = cause.getMessage() != null ? cause.getMessage().toLowerCase() : "";
+
+ return cause instanceof java.net.ConnectException ||
+ cause instanceof java.net.UnknownHostException ||
+ cause instanceof java.nio.channels.ClosedChannelException ||
+ msg.contains("connection") ||
+ msg.contains("timeout") ||
+ msg.contains("refused") ||
+ msg.contains("closed");
+ }
+
+ private static void markServiceAsBlocked(String url) {
+ ServiceType service = detectService(url);
+ if (service == null || service.isAlwaysDirect()) return;
+
+ int fails = serviceFailCount.getOrDefault(service, 0) + 1;
+ serviceFailCount.put(service, fails);
+ serviceLastCheckTime.put(service, System.currentTimeMillis());
+
+ if (fails >= MAX_FAILS_BEFORE_PROXY && !serviceProxyMode.get(service)) {
+ serviceProxyMode.put(service, true);
+ System.out.println(ZAnsi.yellow("[NET] " + service.name() + " заблокирован, переключаемся на прокси"));
+ }
+ }
/**
- * Получить URL через прокси если нужно
+ * Универсальный GET с умным прокси + автоматическим fallback
*/
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
- if (shouldUseProxyForUrl(url)) {
- String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
- return proxyGet("/download?url=" + encodedUrl);
+ // Попытка прямого подключения
+ if (!shouldUseProxyForUrl(url)) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(25))
+ .header("User-Agent", "ZernMC-Launcher/1.0")
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() == 200) {
+ directSuccessCount++;
+ return response.body();
+ }
+
+ if (response.statusCode() >= 400) {
+ throw new IOException("HTTP " + response.statusCode());
+ }
+ } catch (Exception e) {
+ if (isConnectionError(e)) {
+ directFailCount++;
+ markServiceAsBlocked(url);
+ }
+ // Если ошибка соединения — пробуем через прокси
+ }
}
-
+
+ // Через прокси
try {
+ String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
+ String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
+
HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .timeout(Duration.ofSeconds(30))
+ .uri(URI.create(proxyUrl))
+ .timeout(Duration.ofSeconds(40))
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
-
+
HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
-
+
if (response.statusCode() != 200) {
- throw new IOException("HTTP " + response.statusCode());
+ throw new IOException("Proxy HTTP " + response.statusCode());
}
-
- directSuccessCount++;
+
+ proxySuccessCount++;
return response.body();
-
+
} catch (Exception e) {
- directFailCount++;
- ServiceType service = detectService(url);
- if (service != null && !service.isAlwaysDirect()) {
- int fails = serviceFailCount.getOrDefault(service, 0) + 1;
- serviceFailCount.put(service, fails);
- if (fails >= MAX_FAILS_BEFORE_PROXY) {
- serviceProxyMode.put(service, true);
- }
- }
- throw e;
+ throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e);
}
}
-
+
/**
- * Скачать файл с умным прокси
+ * Скачивание файла с умным прокси + fallback
*/
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
- if (shouldUseProxyForUrl(url)) {
- downloadViaProxy(url, target);
- return;
- }
-
- try {
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(url))
- .timeout(Duration.ofSeconds(30))
- .header("User-Agent", "ZernMC-Launcher/1.0")
- .GET()
- .build();
-
- HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
-
- if (response.statusCode() != 200) {
- throw new IOException("HTTP " + response.statusCode());
- }
-
- directSuccessCount++;
-
- } catch (Exception e) {
- directFailCount++;
- ServiceType service = detectService(url);
- if (service != null && !service.isAlwaysDirect()) {
- int fails = serviceFailCount.getOrDefault(service, 0) + 1;
- serviceFailCount.put(service, fails);
- if (fails >= MAX_FAILS_BEFORE_PROXY) {
- serviceProxyMode.put(service, true);
+ if (!shouldUseProxyForUrl(url)) {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofSeconds(40))
+ .header("User-Agent", "ZernMC-Launcher/1.0")
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
+
+ if (response.statusCode() == 200) {
+ directSuccessCount++;
+ return;
}
+ } catch (Exception e) {
+ if (isConnectionError(e)) {
+ directFailCount++;
+ markServiceAsBlocked(url);
+ }
+ // fallback на прокси ниже
}
- throw e;
}
+
+ // Скачивание через прокси
+ String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
+ String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(proxyUrl))
+ .timeout(Duration.ofMinutes(5))
+ .header("User-Agent", "ZernMC-Launcher/1.0")
+ .GET()
+ .build();
+
+ HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Proxy download failed: HTTP " + response.statusCode());
+ }
+
+ proxySuccessCount++;
}
-
- // ====================== ОСНОВНЫЕ МЕТОДЫ (СОХРАНЕНЫ) ======================
-
+
+ // ====================== СТАРЫЕ МЕТОДЫ (обновлённые) ======================
+
public static String get(String endpoint) throws IOException, InterruptedException {
checkAllServicesOnStartup();
-
+
if (useProxyMode.get()) {
return proxyGet(endpoint);
}
-
+
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + endpoint))
@@ -334,20 +386,19 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
-
+
HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
-
+
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
return response.body();
-
} catch (Exception e) {
directFailCount++;
throw e;
}
}
-
+
private static String proxyGet(String endpoint) throws IOException {
try {
HttpRequest request = HttpRequest.newBuilder()
@@ -356,96 +407,64 @@ public class ZHttpClient {
.header("User-Agent", "ZernMC-Launcher/1.0")
.GET()
.build();
-
+
HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString());
-
+
if (response.statusCode() != 200) {
throw new IOException("HTTP " + response.statusCode());
}
-
+
proxySuccessCount++;
return response.body();
-
} catch (Exception e) {
- throw new IOException("Ошибка прокси запроса: " + e.getMessage(), e);
+ throw new IOException("Ошибка прокси: " + e.getMessage(), e);
}
}
-
- private static void downloadViaProxy(String url, Path target) throws Exception {
- String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8.toString());
- String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
-
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(proxyUrl))
- .timeout(Duration.ofMinutes(5))
- .header("User-Agent", "ZernMC-Launcher/1.0")
- .GET()
- .build();
-
- HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
-
- if (response.statusCode() != 200) {
- throw new IOException("HTTP " + response.statusCode());
- }
-
- proxySuccessCount++;
- }
-
+
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
-
+
public static List getFabricLoaderVersions() throws IOException, InterruptedException {
String url = "https://meta.fabricmc.net/v2/versions/loader";
- String response = getWithSmartProxy(url);
- return parseFabricVersionsFromJson(response);
+ return parseFabricVersionsFromJson(getWithSmartProxy(url));
}
-
+
public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException {
String url = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
String response = getWithSmartProxy(url);
return new JSONObject(response);
}
-
+
public static JSONObject getMojangVersionJson(String versionId) throws IOException, InterruptedException {
JSONObject manifest = getMojangVersionManifest();
JSONArray versions = manifest.getJSONArray("versions");
-
- String versionUrl = null;
+
for (int i = 0; i < versions.length(); i++) {
JSONObject v = versions.getJSONObject(i);
if (v.getString("id").equals(versionId)) {
- versionUrl = v.getString("url");
- break;
+ return new JSONObject(getWithSmartProxy(v.getString("url")));
}
}
-
- if (versionUrl == null) {
- throw new IOException("Version " + versionId + " not found");
- }
-
- String response = getWithSmartProxy(versionUrl);
- return new JSONObject(response);
+ throw new IOException("Version " + versionId + " not found");
}
-
+
public static String getForgeVersionsXml() throws IOException, InterruptedException {
String url = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml";
return getWithSmartProxy(url);
}
-
+
public static void downloadFile(String url, Path target) throws Exception {
downloadFileWithSmartProxy(url, target);
}
-
+
public static void downloadAsset(String hash, Path target) throws Exception {
String url = "https://resources.download.minecraft.net/" + hash.substring(0, 2) + "/" + hash;
downloadFileWithSmartProxy(url, target);
}
-
+
public static String downloadString(String url) throws IOException, InterruptedException {
return getWithSmartProxy(url);
}
-
- // ====================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ======================
-
+
private static List parseFabricVersionsFromJson(String json) {
JSONArray array = new JSONArray(json);
List versions = new ArrayList<>();
@@ -457,20 +476,22 @@ public class ZHttpClient {
}
return versions;
}
-
+
+ // ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
+
public static String getBaseUrl() {
return BASE_URL;
}
-
+
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
return get("/launcher/version");
}
-
+
public static void forceProxyMode() {
useProxyMode.set(true);
System.out.println(ZAnsi.yellow("Принудительно включен глобальный прокси режим"));
}
-
+
public static void disableProxyMode() {
useProxyMode.set(false);
for (ServiceType type : ServiceType.values()) {
@@ -481,23 +502,23 @@ public class ZHttpClient {
}
System.out.println(ZAnsi.green("Режим прокси выключен"));
}
-
+
public static boolean isProxyMode() {
return useProxyMode.get();
}
-
+
public static void printStats() {
System.out.println(ZAnsi.cyan("\n=== Статистика сети ==="));
- System.out.println(ZAnsi.white("Глобальный прокси режим: ") + (useProxyMode.get() ? "ВКЛЮЧЕН" : "ВЫКЛЮЧЕН"));
+ System.out.println(ZAnsi.white("Глобальный прокси: ") + (useProxyMode.get() ? "ВКЛ" : "ВЫКЛ"));
System.out.println(ZAnsi.white("Прямых успехов: ") + directSuccessCount);
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount);
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount);
-
+
System.out.println(ZAnsi.cyan("\nСтатус сервисов:"));
for (ServiceType type : ServiceType.values()) {
if (type.isAlwaysDirect()) continue;
String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО");
- String health = serviceHealthy.get(type) ? ZAnsi.green("✓") : ZAnsi.red("✗");
+ String health = serviceHealthy.get(type) ? ZAnsi.green("[+]") : ZAnsi.red("[-]");
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
}
}
diff --git a/server/auth.py b/server/auth.py
new file mode 100644
index 0000000..5872ca8
--- /dev/null
+++ b/server/auth.py
@@ -0,0 +1,357 @@
+# auth.py
+import base64
+import json
+import sqlite3
+import hashlib
+import hmac
+import secrets
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Optional
+
+import structlog
+from fastapi import APIRouter, HTTPException, Request, Depends
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from pydantic import BaseModel, Field
+
+logger = structlog.get_logger(__name__)
+
+# ====================== КОНФИГ ======================
+AUTH_DB = Path("data/auth.db")
+AUTH_DB.parent.mkdir(exist_ok=True)
+
+SECRET_KEY = Path("data/.secret_key")
+
+ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа
+REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней
+
+# ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
+def _get_secret() -> bytes:
+ if SECRET_KEY.exists():
+ return SECRET_KEY.read_bytes()
+ key = secrets.token_bytes(64)
+ SECRET_KEY.write_bytes(key)
+ SECRET_KEY.chmod(0o600)
+ return key
+
+_SECRET = _get_secret()
+
+def create_jwt(payload: dict) -> str:
+ import base64, json
+ header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).rstrip(b'=').decode()
+ body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
+ msg = f"{header}.{body}".encode()
+ sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
+ return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
+
+def verify_jwt(token: str) -> Optional[dict]:
+ try:
+ parts = token.split(".")
+ if len(parts) != 3:
+ return None
+ header, body, sig = parts
+ msg = f"{header}.{body}".encode()
+ expected = hmac.new(_SECRET, msg, hashlib.sha256).digest()
+ if not hmac.compare_digest(base64.urlsafe_b64decode(sig + '==='[:3]), expected):
+ return None
+ payload = json.loads(base64.urlsafe_b64decode(body + '==='[:3]))
+ if payload.get("exp", 0) < time.time():
+ return None
+ return payload
+ except:
+ return None
+
+# ====================== БАЗА ДАННЫХ ======================
+def get_db():
+ conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+def init_db():
+ conn = get_db()
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY,
+ username TEXT UNIQUE COLLATE NOCASE,
+ password_hash TEXT NOT NULL,
+ uuid TEXT UNIQUE NOT NULL,
+ created_at REAL NOT NULL,
+ last_login REAL
+ );
+
+ CREATE TABLE IF NOT EXISTS passes ( -- НОВАЯ ТАБЛИЦА
+ code TEXT PRIMARY KEY, -- ZERN-XXXXXX
+ is_used BOOLEAN DEFAULT 0,
+ activated_by INTEGER REFERENCES users(id),
+ activated_at REAL,
+ expires_at REAL, -- NULL = без срока
+ max_uses INTEGER DEFAULT 1, -- пока 1, можно больше
+ uses INTEGER DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS user_passes ( -- Связь пользователь-пасс
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ pass_code TEXT REFERENCES passes(code),
+ activated_at REAL NOT NULL,
+ PRIMARY KEY (user_id, pass_code)
+ );
+ """)
+ conn.commit()
+ conn.close()
+ logger.info("Auth database initialized")
+
+# ====================== ХЕЛПЕРЫ ======================
+def hash_password(password: str) -> str:
+ salt = secrets.token_hex(16)
+ hash_obj = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 300000)
+ return f"{salt}${hash_obj.hex()}"
+
+def verify_password(password: str, stored: str) -> bool:
+ try:
+ salt, stored_hash = stored.split('$')
+ hash_obj = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 300000)
+ return hmac.compare_digest(hash_obj.hex(), stored_hash)
+ except:
+ return False
+
+def generate_uuid() -> str:
+ return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
+
+# ====================== МОДЕЛИ ======================
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+class RegisterRequest(BaseModel):
+ username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$")
+ password: str = Field(..., min_length=6, max_length=128)
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ expires_in: int
+ username: str
+ uuid: str
+
+# ====================== ROUTER ======================
+router = APIRouter(prefix="/auth", tags=["auth"])
+bearer = HTTPBearer(auto_error=False)
+
+@router.post("/register", response_model=TokenResponse)
+async def register(body: RegisterRequest, request: Request):
+ conn = get_db()
+ try:
+ if conn.execute("SELECT 1 FROM users WHERE username = ? COLLATE NOCASE", (body.username,)).fetchone():
+ raise HTTPException(409, "Имя пользователя уже занято")
+
+ uuid = generate_uuid()
+ pw_hash = hash_password(body.password)
+ now = time.time()
+
+ conn.execute(
+ "INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)",
+ (body.username, pw_hash, uuid, now)
+ )
+ user_id = conn.lastrowid
+ conn.commit()
+
+ return _issue_tokens(conn, user_id, body.username, uuid)
+ finally:
+ conn.close()
+
+
+@router.post("/login", response_model=TokenResponse)
+async def login(body: LoginRequest, request: Request):
+ conn = get_db()
+ try:
+ row = conn.execute(
+ "SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE",
+ (body.username,)
+ ).fetchone()
+
+ if not row or not verify_password(body.password, row["password_hash"]):
+ raise HTTPException(401, "Неверное имя пользователя или пароль")
+
+ conn.execute("UPDATE users SET last_login = ? WHERE id = ?", (time.time(), row["id"]))
+ conn.commit()
+
+ return _issue_tokens(conn, row["id"], row["username"], row["uuid"])
+ finally:
+ conn.close()
+
+
+def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse:
+ now = time.time()
+
+ access_token = create_jwt({
+ "sub": user_id,
+ "username": username,
+ "uuid": uuid,
+ "type": "access",
+ "exp": now + ACCESS_TOKEN_EXPIRE_SECONDS
+ })
+
+ refresh_token = create_jwt({
+ "sub": user_id,
+ "type": "refresh",
+ "exp": now + REFRESH_TOKEN_EXPIRE_SECONDS
+ })
+
+ token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
+
+ # Удаляем старые refresh-токены пользователя
+ conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,))
+ conn.execute(
+ "INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
+ (user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS)
+ )
+ conn.commit()
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ expires_in=ACCESS_TOKEN_EXPIRE_SECONDS,
+ username=username,
+ uuid=uuid
+ )
+
+
+# Добавь этот эндпоинт, он используется в tryRefresh()
+@router.post("/refresh")
+async def refresh(body: dict):
+ refresh_token = body.get("refresh_token")
+ if not refresh_token:
+ raise HTTPException(400, "refresh_token обязателен")
+
+ payload = verify_jwt(refresh_token)
+ if not payload or payload.get("type") != "refresh":
+ raise HTTPException(401, "Недействительный refresh token")
+
+ conn = get_db()
+ try:
+ token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
+ row = conn.execute(
+ "SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
+ (token_hash, time.time())
+ ).fetchone()
+
+ if not row:
+ raise HTTPException(401, "Refresh token истёк или недействителен")
+
+ user_row = conn.execute(
+ "SELECT id, username, uuid FROM users WHERE id = ?", (row["user_id"],)
+ ).fetchone()
+
+ if not user_row:
+ raise HTTPException(401, "Пользователь не найден")
+
+ return _issue_tokens(conn, user_row["id"], user_row["username"], user_row["uuid"])
+ finally:
+ conn.close()
+
+class ActivatePassRequest(BaseModel):
+ pass_code: str = Field(..., min_length=8, max_length=20, pattern=r"^ZERN-[A-Z0-9]+$")
+
+class PassInfo(BaseModel):
+ code: str
+ expires_at: Optional[float] = None
+ is_active: bool
+
+@router.post("/pass/activate")
+async def activate_pass(body: ActivatePassRequest, credentials: HTTPAuthorizationCredentials = Depends(bearer)):
+ token = credentials.credentials if credentials else None
+ if not token:
+ raise HTTPException(401, "Требуется авторизация")
+
+ payload = verify_jwt(token)
+ if not payload or payload.get("type") != "access":
+ raise HTTPException(401, "Недействительный токен")
+
+ user_id = payload["sub"]
+
+ conn = get_db()
+ try:
+ # Проверяем существование и доступность пасса
+ pass_row = conn.execute(
+ "SELECT code, expires_at, uses, max_uses FROM passes WHERE code = ?",
+ (body.pass_code.upper(),)
+ ).fetchone()
+
+ if not pass_row:
+ raise HTTPException(404, "Проходка не найдена")
+
+ if pass_row["expires_at"] and pass_row["expires_at"] < time.time():
+ raise HTTPException(410, "Проходка истекла")
+
+ if pass_row["uses"] >= pass_row["max_uses"]:
+ raise HTTPException(410, "Проходка уже использована")
+
+ # Проверяем, не активировал ли уже этот пользователь
+ already = conn.execute(
+ "SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?",
+ (user_id, body.pass_code.upper())
+ ).fetchone()
+
+ if already:
+ raise HTTPException(409, "Эта проходка уже активирована на вашем аккаунте")
+
+ now = time.time()
+
+ # Активируем
+ conn.execute(
+ "INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)",
+ (user_id, body.pass_code.upper(), now)
+ )
+
+ # Увеличиваем счётчик использований
+ conn.execute(
+ "UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?",
+ (user_id, now, body.pass_code.upper())
+ )
+
+ conn.commit()
+
+ logger.info("Pass activated", user_id=user_id, pass_code=body.pass_code)
+ return {"success": True, "message": "Проходка успешно активирована!"}
+
+ finally:
+ conn.close()
+
+
+@router.get("/pass/my")
+async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)):
+ token = credentials.credentials if credentials else None
+ if not token:
+ raise HTTPException(401, "Требуется авторизация")
+
+ payload = verify_jwt(token)
+ if not payload:
+ raise HTTPException(401, "Недействительный токен")
+
+ user_id = payload["sub"]
+
+ conn = get_db()
+ try:
+ rows = conn.execute("""
+ SELECT p.code, p.expires_at, up.activated_at
+ FROM user_passes up
+ JOIN passes p ON up.pass_code = p.code
+ WHERE up.user_id = ?
+ """, (user_id,)).fetchall()
+
+ passes = []
+ now = time.time()
+ for row in rows:
+ expires = row["expires_at"]
+ is_active = expires is None or expires > now
+ passes.append({
+ "code": row["code"],
+ "activated_at": row["activated_at"],
+ "expires_at": expires,
+ "is_active": is_active
+ })
+
+ return {"passes": passes}
+ finally:
+ conn.close()
\ No newline at end of file
diff --git a/server/main.py b/server/main.py
index 9dc25c4..cf9e0fa 100644
--- a/server/main.py
+++ b/server/main.py
@@ -9,7 +9,6 @@ import logging
from datetime import datetime
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
-# Import local modules
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta
from middleware import LoggingMiddleware
@@ -20,6 +19,9 @@ import httpx
import base64
from fastapi.responses import StreamingResponse
+from auth import router as auth_router, init_db, verify_jwt
+from pass_manager import activate_pass, has_active_pass, get_user_passes
+
logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes
@@ -50,6 +52,10 @@ async def lifespan(app: FastAPI):
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)
+
+ init_db()
+
+ app.include_router(auth_router)
if args.test:
await run_test_mode()
@@ -820,6 +826,49 @@ async def proxy_status():
}
+@app.post("/auth/pass/activate")
+async def api_activate_pass(request: Request):
+ try:
+ body = await request.json()
+ pass_code = body.get("pass_code")
+ if not pass_code:
+ raise HTTPException(400, "pass_code обязателен")
+ except:
+ raise HTTPException(400, "Неверный JSON")
+
+ # Получаем текущего пользователя из токена
+ auth_header = request.headers.get("Authorization")
+ if not auth_header or not auth_header.startswith("Bearer "):
+ raise HTTPException(401, "Требуется авторизация")
+
+ token = auth_header.split(" ")[1]
+ payload = verify_jwt(token) # функция из auth.py
+ if not payload:
+ raise HTTPException(401, "Недействительный токен")
+
+ result = activate_pass(pass_code, payload["username"], payload["sub"])
+
+ if result["success"]:
+ return result
+ else:
+ raise HTTPException(400, result["error"])
+
+
+@app.get("/auth/pass/my")
+async def api_my_passes(request: Request):
+ auth_header = request.headers.get("Authorization")
+ if not auth_header or not auth_header.startswith("Bearer "):
+ raise HTTPException(401, "Требуется авторизация")
+
+ token = auth_header.split(" ")[1]
+ payload = verify_jwt(token)
+ if not payload:
+ raise HTTPException(401, "Недействительный токен")
+
+ passes = get_user_passes(payload["username"])
+ return {"passes": passes, "has_active": any(p["is_active"] for p in passes)}
+
+
# Cleanup on shutdown
@app.on_event("shutdown")
async def shutdown_proxy():
diff --git a/server/pass_manager.py b/server/pass_manager.py
new file mode 100644
index 0000000..574fcb6
--- /dev/null
+++ b/server/pass_manager.py
@@ -0,0 +1,77 @@
+import json
+from pathlib import Path
+from datetime import datetime
+import structlog
+
+logger = structlog.get_logger(__name__)
+
+PASSES_FILE = Path("data/passes.json")
+
+def load_passes():
+ if not PASSES_FILE.exists():
+ PASSES_FILE.parent.mkdir(exist_ok=True)
+ default = {"passes": {}}
+ PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False))
+ return default
+ try:
+ return json.loads(PASSES_FILE.read_text(encoding="utf-8"))
+ except:
+ return {"passes": {}}
+
+def save_passes(data):
+ PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
+
+def activate_pass(pass_code: str, username: str, user_id: int) -> dict:
+ data = load_passes()
+ pass_code = pass_code.upper().strip()
+
+ if pass_code not in data["passes"]:
+ return {"success": False, "error": "Проходка не найдена"}
+
+ p = data["passes"][pass_code]
+
+ if not p.get("is_active", True):
+ return {"success": False, "error": "Проходка деактивирована"}
+
+ if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
+ return {"success": False, "error": "Проходка истекла"}
+
+ if p.get("owner") is not None:
+ if p.get("owner") != username:
+ return {"success": False, "error": "Проходка уже активирована другим пользователем"}
+ return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
+
+ # Активация
+ now = datetime.now().timestamp()
+ p["owner"] = username
+ p["activated_at"] = now
+ p["uses"] = p.get("uses", 0) + 1
+
+ save_passes(data)
+
+ logger.info("Pass activated", pass_code=pass_code, username=username)
+ return {"success": True, "message": "Проходка успешно активирована!"}
+
+def has_active_pass(username: str) -> bool:
+ data = load_passes()
+ for p in data["passes"].values():
+ if p.get("owner") == username:
+ if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
+ continue
+ if p.get("is_active", True):
+ return True
+ return False
+
+def get_user_passes(username: str) -> list:
+ data = load_passes()
+ result = []
+ now = datetime.now().timestamp()
+ for p in data["passes"].values():
+ if p.get("owner") == username:
+ result.append({
+ "code": p["code"],
+ "activated_at": p.get("activated_at"),
+ "expires_at": p.get("expires_at"),
+ "is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now)
+ })
+ return result
\ No newline at end of file