Попытка добавления проходок, аккаунтов, а так же доработка прокси
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<groupId>me.sashegdev</groupId>
|
<groupId>me.sashegdev</groupId>
|
||||||
<artifactId>ZernMCLauncher</artifactId>
|
<artifactId>ZernMCLauncher</artifactId>
|
||||||
<version>1.0.5</version>
|
<version>1.0.6</version>
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package me.sashegdev.zernmc.launcher;
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
import me.sashegdev.zernmc.launcher.menu.*;
|
import me.sashegdev.zernmc.launcher.menu.*;
|
||||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||||
import me.sashegdev.zernmc.launcher.utils.*;
|
import me.sashegdev.zernmc.launcher.utils.*;
|
||||||
@@ -17,8 +18,12 @@ public class Main {
|
|||||||
|
|
||||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
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("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();
|
ZAnsi.install();
|
||||||
|
|
||||||
System.out.print("\033[H\033[2J");
|
System.out.print("\033[H\033[2J");
|
||||||
@@ -29,6 +34,23 @@ public class Main {
|
|||||||
|
|
||||||
checkAndAutoUpdateLauncher();
|
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 {
|
try {
|
||||||
mainLoop();
|
mainLoop();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -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<String> 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<String> 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<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package me.sashegdev.zernmc.launcher.menu;
|
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.Instance;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
|
import me.sashegdev.zernmc.launcher.minecraft.MinecraftLib;
|
||||||
@@ -80,6 +81,32 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void installServerPack() throws Exception {
|
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();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера..."));
|
System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера..."));
|
||||||
|
|
||||||
@@ -502,11 +529,22 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void launchExistingInstance(Instance instance) {
|
private void launchExistingInstance(Instance instance) {
|
||||||
|
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||||
|
|
||||||
MinecraftLib lib = new MinecraftLib(instance);
|
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 {
|
try {
|
||||||
lib.launch(options);
|
lib.launch(options);
|
||||||
|
|||||||
@@ -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<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,10 @@ public class Config {
|
|||||||
return CONFIG_DIR.resolve("jre");
|
return CONFIG_DIR.resolve("jre");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Path getConfigDir() {
|
||||||
|
return CONFIG_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Полезная информация для пользователя
|
* Полезная информация для пользователя
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
package me.sashegdev.zernmc.launcher.utils;
|
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;
|
import java.util.Scanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Улучшенный Input с поддержкой кириллицы и confirm через ArrowMenu
|
||||||
|
*/
|
||||||
public class Input {
|
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() {
|
public static String readLine() {
|
||||||
return scanner.nextLine().trim();
|
return scanner.nextLine().trim();
|
||||||
@@ -36,9 +44,39 @@ public class Input {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean confirm(String prompt) {
|
/**
|
||||||
System.out.print(prompt + " (да/нет): ");
|
* Новый confirm через ArrowMenu
|
||||||
String answer = scanner.nextLine().trim().toLowerCase();
|
* @throws IOException
|
||||||
return answer.equals("да") || answer.equals("y") || answer.equals("yes");
|
*/
|
||||||
|
public static boolean confirm(String question) throws IOException {
|
||||||
|
ConsoleUtils.clearScreen(); // опционально, можно убрать
|
||||||
|
|
||||||
|
List<String> 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<String> options = List.of("Да", "Нет");
|
||||||
|
ArrowMenu menu = new ArrowMenu(question, options);
|
||||||
|
int choice = menu.show();
|
||||||
|
return choice == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрытие сканнера (вызывать при выходе из программы, если нужно)
|
||||||
|
*/
|
||||||
|
public static void close() {
|
||||||
|
scanner.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
// Умное проксирование по сервисам
|
// Умное проксирование по сервисам
|
||||||
public enum ServiceType {
|
public enum ServiceType {
|
||||||
ZERN_SERVER(BASE_URL, true), // Всегда прямое подключение
|
ZERN_SERVER(BASE_URL, true),
|
||||||
FABRIC_META("https://meta.fabricmc.net", false),
|
FABRIC_META("https://meta.fabricmc.net", false),
|
||||||
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
||||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||||
@@ -50,13 +50,8 @@ public class ZHttpClient {
|
|||||||
this.alwaysDirect = alwaysDirect;
|
this.alwaysDirect = alwaysDirect;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getBaseUrl() {
|
public String getBaseUrl() { return baseUrl; }
|
||||||
return baseUrl;
|
public boolean isAlwaysDirect() { return alwaysDirect; }
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isAlwaysDirect() {
|
|
||||||
return alwaysDirect;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Статусы сервисов
|
// Статусы сервисов
|
||||||
@@ -67,7 +62,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
private static final int MAX_FAILS_BEFORE_PROXY = 2;
|
private static final int MAX_FAILS_BEFORE_PROXY = 2;
|
||||||
private static final long HEALTH_CHECK_INTERVAL_MS = 60000; // 1 минута
|
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 directSuccessCount = 0;
|
||||||
@@ -83,7 +78,7 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверить все сервисы при старте
|
* Вызывать один раз при запуске лаунчера
|
||||||
*/
|
*/
|
||||||
public static void checkAllServicesOnStartup() {
|
public static void checkAllServicesOnStartup() {
|
||||||
if (proxyTested.get()) return;
|
if (proxyTested.get()) return;
|
||||||
@@ -91,12 +86,13 @@ public class ZHttpClient {
|
|||||||
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
|
System.out.println(ZAnsi.cyan("Проверка доступности сервисов..."));
|
||||||
|
|
||||||
List<ServiceType> servicesToCheck = List.of(
|
List<ServiceType> servicesToCheck = List.of(
|
||||||
ServiceType.ZERN_SERVER,
|
ServiceType.ZERN_SERVER,
|
||||||
ServiceType.GOOGLE,
|
ServiceType.GOOGLE,
|
||||||
ServiceType.FABRIC_META,
|
ServiceType.FABRIC_META,
|
||||||
ServiceType.FABRIC_MAVEN,
|
ServiceType.FABRIC_MAVEN,
|
||||||
ServiceType.MOJANG_META,
|
ServiceType.MOJANG_META,
|
||||||
ServiceType.FORGE_MAVEN
|
ServiceType.MOJANG_RESOURCES,
|
||||||
|
ServiceType.FORGE_MAVEN
|
||||||
);
|
);
|
||||||
|
|
||||||
for (ServiceType service : servicesToCheck) {
|
for (ServiceType service : servicesToCheck) {
|
||||||
@@ -104,11 +100,9 @@ public class ZHttpClient {
|
|||||||
serviceHealthy.put(service, isHealthy);
|
serviceHealthy.put(service, isHealthy);
|
||||||
|
|
||||||
if (service.isAlwaysDirect()) {
|
if (service.isAlwaysDirect()) {
|
||||||
if (!isHealthy) {
|
System.out.println(isHealthy ?
|
||||||
System.out.println(ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
|
ZAnsi.green(" " + service.name() + " - OK") :
|
||||||
} else {
|
ZAnsi.red(" " + service.name() + " - НЕ ДОСТУПЕН (критично!)"));
|
||||||
System.out.println(ZAnsi.green(" " + service.name() + " - OK"));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (isHealthy) {
|
if (isHealthy) {
|
||||||
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
|
System.out.println(ZAnsi.green(" " + service.name() + " - прямое подключение работает"));
|
||||||
@@ -120,51 +114,69 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, нужно ли включить глобальный прокси режим
|
if (!serviceHealthy.get(ServiceType.ZERN_SERVER)) {
|
||||||
boolean anyCriticalDown = !serviceHealthy.get(ServiceType.ZERN_SERVER);
|
|
||||||
if (anyCriticalDown) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
|
System.out.println(ZAnsi.brightRed("Критическая ошибка: Zern сервер недоступен!"));
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyTested.set(true);
|
proxyTested.set(true);
|
||||||
|
|
||||||
// Запускаем фоновую проверку
|
|
||||||
startHealthCheckThread();
|
startHealthCheckThread();
|
||||||
|
|
||||||
printStats();
|
printStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверить здоровье конкретного сервиса
|
* Принудительная проверка Mojang-сервисов (рекомендуется вызывать перед установкой сборки)
|
||||||
*/
|
*/
|
||||||
private static boolean checkServiceHealth(ServiceType service) {
|
public static void forceCheckMojangServices() {
|
||||||
if (service.isAlwaysDirect()) {
|
System.out.println(ZAnsi.cyan("Принудительная проверка Mojang сервисов..."));
|
||||||
return checkDirectConnection(service.getBaseUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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());
|
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 {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.uri(URI.create(testUrl))
|
||||||
.timeout(Duration.ofMillis(CHECK_TIMEOUT_MS))
|
.timeout(Duration.ofMillis(CHECK_TIMEOUT_MS))
|
||||||
.HEAD()
|
.GET()
|
||||||
|
.header("User-Agent", "ZernMC-Launcher/HealthCheck")
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<Void> response = client.send(request, HttpResponse.BodyHandlers.discarding());
|
|
||||||
return response.statusCode() < 500;
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
int code = response.statusCode();
|
||||||
|
return code == 200 || code == 404; // 404 для ресурсов — нормально
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Запустить фоновый поток для периодической проверки
|
|
||||||
*/
|
|
||||||
private static void startHealthCheckThread() {
|
private static void startHealthCheckThread() {
|
||||||
Thread healthThread = new Thread(() -> {
|
Thread healthThread = new Thread(() -> {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -180,9 +192,6 @@ public class ZHttpClient {
|
|||||||
healthThread.start();
|
healthThread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Периодическая проверка здоровья сервисов
|
|
||||||
*/
|
|
||||||
private static void performHealthCheck() {
|
private static void performHealthCheck() {
|
||||||
for (ServiceType service : ServiceType.values()) {
|
for (ServiceType service : ServiceType.values()) {
|
||||||
if (service.isAlwaysDirect()) continue;
|
if (service.isAlwaysDirect()) continue;
|
||||||
@@ -191,13 +200,9 @@ public class ZHttpClient {
|
|||||||
serviceHealthy.put(service, isHealthy);
|
serviceHealthy.put(service, isHealthy);
|
||||||
|
|
||||||
if (isHealthy && serviceProxyMode.get(service)) {
|
if (isHealthy && serviceProxyMode.get(service)) {
|
||||||
// Сервис восстановился - пробуем переключить обратно
|
serviceProxyMode.put(service, false);
|
||||||
Long lastCheck = serviceLastCheckTime.get(service);
|
serviceFailCount.put(service, 0);
|
||||||
if (lastCheck == null || System.currentTimeMillis() - lastCheck > HEALTH_CHECK_INTERVAL_MS) {
|
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)) {
|
} else if (!isHealthy && !serviceProxyMode.get(service)) {
|
||||||
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
int fails = serviceFailCount.getOrDefault(service, 0) + 1;
|
||||||
serviceFailCount.put(service, fails);
|
serviceFailCount.put(service, fails);
|
||||||
@@ -211,9 +216,6 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Определить тип сервиса по URL
|
|
||||||
*/
|
|
||||||
private static ServiceType detectService(String url) {
|
private static ServiceType detectService(String url) {
|
||||||
if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META;
|
if (url.contains("meta.fabricmc.net")) return ServiceType.FABRIC_META;
|
||||||
if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN;
|
if (url.contains("maven.fabricmc.net")) return ServiceType.FABRIC_MAVEN;
|
||||||
@@ -226,32 +228,82 @@ public class ZHttpClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Нужно ли использовать прокси для URL
|
|
||||||
*/
|
|
||||||
private static boolean shouldUseProxyForUrl(String url) {
|
private static boolean shouldUseProxyForUrl(String url) {
|
||||||
if (useProxyMode.get()) return true;
|
if (useProxyMode.get()) return true;
|
||||||
|
|
||||||
ServiceType service = detectService(url);
|
ServiceType service = detectService(url);
|
||||||
if (service == null) return false;
|
if (service == null || service.isAlwaysDirect()) return false;
|
||||||
if (service.isAlwaysDirect()) return false;
|
|
||||||
|
|
||||||
return serviceProxyMode.getOrDefault(service, 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 {
|
public static String getWithSmartProxy(String url) throws IOException, InterruptedException {
|
||||||
if (shouldUseProxyForUrl(url)) {
|
// Попытка прямого подключения
|
||||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
if (!shouldUseProxyForUrl(url)) {
|
||||||
return proxyGet("/download?url=" + encodedUrl);
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.timeout(Duration.ofSeconds(25))
|
||||||
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> 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 {
|
try {
|
||||||
|
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||||
|
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(url))
|
.uri(URI.create(proxyUrl))
|
||||||
.timeout(Duration.ofSeconds(30))
|
.timeout(Duration.ofSeconds(40))
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
.GET()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
@@ -259,66 +311,66 @@ public class ZHttpClient {
|
|||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
throw new IOException("HTTP " + response.statusCode());
|
throw new IOException("Proxy HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
directSuccessCount++;
|
proxySuccessCount++;
|
||||||
return response.body();
|
return response.body();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
directFailCount++;
|
throw new IOException("Не удалось получить данные ни напрямую, ни через прокси: " + e.getMessage(), e);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Скачать файл с умным прокси
|
* Скачивание файла с умным прокси + fallback
|
||||||
*/
|
*/
|
||||||
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
public static void downloadFileWithSmartProxy(String url, Path target) throws Exception {
|
||||||
if (shouldUseProxyForUrl(url)) {
|
if (!shouldUseProxyForUrl(url)) {
|
||||||
downloadViaProxy(url, target);
|
try {
|
||||||
return;
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
}
|
.uri(URI.create(url))
|
||||||
|
.timeout(Duration.ofSeconds(40))
|
||||||
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
try {
|
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(url))
|
|
||||||
.timeout(Duration.ofSeconds(30))
|
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
if (response.statusCode() == 200) {
|
||||||
|
directSuccessCount++;
|
||||||
if (response.statusCode() != 200) {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
} 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<Path> 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 {
|
public static String get(String endpoint) throws IOException, InterruptedException {
|
||||||
checkAllServicesOnStartup();
|
checkAllServicesOnStartup();
|
||||||
@@ -341,7 +393,6 @@ public class ZHttpClient {
|
|||||||
throw new IOException("HTTP " + response.statusCode());
|
throw new IOException("HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
return response.body();
|
return response.body();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
directFailCount++;
|
directFailCount++;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -365,38 +416,16 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
proxySuccessCount++;
|
proxySuccessCount++;
|
||||||
return response.body();
|
return response.body();
|
||||||
|
|
||||||
} catch (Exception e) {
|
} 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<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(target));
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("HTTP " + response.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
proxySuccessCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
|
// ====================== МЕТОДЫ ДЛЯ EXTERNAL РЕСУРСОВ ======================
|
||||||
|
|
||||||
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
|
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
|
||||||
String url = "https://meta.fabricmc.net/v2/versions/loader";
|
String url = "https://meta.fabricmc.net/v2/versions/loader";
|
||||||
String response = getWithSmartProxy(url);
|
return parseFabricVersionsFromJson(getWithSmartProxy(url));
|
||||||
return parseFabricVersionsFromJson(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException {
|
public static JSONObject getMojangVersionManifest() throws IOException, InterruptedException {
|
||||||
@@ -409,21 +438,13 @@ public class ZHttpClient {
|
|||||||
JSONObject manifest = getMojangVersionManifest();
|
JSONObject manifest = getMojangVersionManifest();
|
||||||
JSONArray versions = manifest.getJSONArray("versions");
|
JSONArray versions = manifest.getJSONArray("versions");
|
||||||
|
|
||||||
String versionUrl = null;
|
|
||||||
for (int i = 0; i < versions.length(); i++) {
|
for (int i = 0; i < versions.length(); i++) {
|
||||||
JSONObject v = versions.getJSONObject(i);
|
JSONObject v = versions.getJSONObject(i);
|
||||||
if (v.getString("id").equals(versionId)) {
|
if (v.getString("id").equals(versionId)) {
|
||||||
versionUrl = v.getString("url");
|
return new JSONObject(getWithSmartProxy(v.getString("url")));
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw new IOException("Version " + versionId + " not found");
|
||||||
if (versionUrl == null) {
|
|
||||||
throw new IOException("Version " + versionId + " not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
String response = getWithSmartProxy(versionUrl);
|
|
||||||
return new JSONObject(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getForgeVersionsXml() throws IOException, InterruptedException {
|
public static String getForgeVersionsXml() throws IOException, InterruptedException {
|
||||||
@@ -444,8 +465,6 @@ public class ZHttpClient {
|
|||||||
return getWithSmartProxy(url);
|
return getWithSmartProxy(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ======================
|
|
||||||
|
|
||||||
private static List<String> parseFabricVersionsFromJson(String json) {
|
private static List<String> parseFabricVersionsFromJson(String json) {
|
||||||
JSONArray array = new JSONArray(json);
|
JSONArray array = new JSONArray(json);
|
||||||
List<String> versions = new ArrayList<>();
|
List<String> versions = new ArrayList<>();
|
||||||
@@ -458,6 +477,8 @@ public class ZHttpClient {
|
|||||||
return versions;
|
return versions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
|
||||||
|
|
||||||
public static String getBaseUrl() {
|
public static String getBaseUrl() {
|
||||||
return BASE_URL;
|
return BASE_URL;
|
||||||
}
|
}
|
||||||
@@ -488,7 +509,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
public static void printStats() {
|
public static void printStats() {
|
||||||
System.out.println(ZAnsi.cyan("\n=== Статистика сети ==="));
|
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("Прямых успехов: ") + directSuccessCount);
|
||||||
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount);
|
System.out.println(ZAnsi.white("Прямых неудач: ") + directFailCount);
|
||||||
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount);
|
System.out.println(ZAnsi.white("Прокси успехов: ") + proxySuccessCount);
|
||||||
@@ -497,7 +518,7 @@ public class ZHttpClient {
|
|||||||
for (ServiceType type : ServiceType.values()) {
|
for (ServiceType type : ServiceType.values()) {
|
||||||
if (type.isAlwaysDirect()) continue;
|
if (type.isAlwaysDirect()) continue;
|
||||||
String status = serviceProxyMode.get(type) ? ZAnsi.red("ПРОКСИ") : ZAnsi.green("ПРЯМО");
|
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);
|
System.out.println(ZAnsi.white(" " + type.name() + ": ") + status + " " + health);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+357
@@ -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()
|
||||||
+50
-1
@@ -9,7 +9,6 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
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 pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||||
from models import PackMeta
|
from models import PackMeta
|
||||||
from middleware import LoggingMiddleware
|
from middleware import LoggingMiddleware
|
||||||
@@ -20,6 +19,9 @@ import httpx
|
|||||||
import base64
|
import base64
|
||||||
from fastapi.responses import StreamingResponse
|
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__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
# Cache for manifests - expires after 5 minutes
|
# Cache for manifests - expires after 5 minutes
|
||||||
@@ -51,6 +53,10 @@ async def lifespan(app: FastAPI):
|
|||||||
PACKS_DIR.mkdir(exist_ok=True)
|
PACKS_DIR.mkdir(exist_ok=True)
|
||||||
DATA_DIR.mkdir(exist_ok=True)
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|
||||||
if args.test:
|
if args.test:
|
||||||
await run_test_mode()
|
await run_test_mode()
|
||||||
yield
|
yield
|
||||||
@@ -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
|
# Cleanup on shutdown
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_proxy():
|
async def shutdown_proxy():
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user