Merge pull request #4 from SashegDev/alpha
Ептить медж из альфы! СПУСТЯ СТОЛЬКО ВРЕМЕНИ ЕБААААТ
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -9,3 +9,5 @@ jre
|
||||
.vscode
|
||||
dependency-reduced-pom.xml
|
||||
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
||||
telegram-bot/
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 SashegDev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -17,21 +17,34 @@
|
||||
|
||||
## Чего пока нет в лаунчере
|
||||
|
||||
- Графического интерфейса (GUI) — только TUI
|
||||
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
||||
- Поддержки **Forge** (в разработке)
|
||||
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
||||
- Раздела новостей об обновлениях Minecraft и лаунчера
|
||||
- Выбора готовых пресетов оптимизации JVM
|
||||
- Кастомных модов (UI, спавнеры, DPI, карточки)
|
||||
- Сайта для лаунчера и сервера
|
||||
- Трекинга наигранного времени
|
||||
|
||||
## Что планируется доработать в ближайшее время
|
||||
|
||||
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
|
||||
- **GUI мод** — привести в единый стиль с главным меню
|
||||
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
|
||||
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
|
||||
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
|
||||
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
|
||||
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
|
||||
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
|
||||
- Генерацию команды запуска Minecraft
|
||||
- Стабильную работу автообновления лаунчера
|
||||
- Полноценные настройки
|
||||
- Стабильность и производительность серверной части
|
||||
- **Улучшенный античит / ClientChecker** — проверка подлинности клиента при подключении к серверу, без нужного клиента не пустит; поставляется вместе с лаунчером, не общедоступный. Хеш-проверка всех папок и файлов сборки при каждом запуске — при несовпадении одного хеша все моды переустанавливаются. Игнорируются только: логи, ресурспаки, шейдеры, сейвы, личные файлы. Защита от подмены libs и лоадеров (Meteor и аналоги), проверка целостности модов через хеши. В перспективе — защита от Mixin-атак (перехват логики других модов), сбор отчёта о текущей сборке и сравнение с базовой
|
||||
- **Баг-фиксы сервера:** подключить `admin_router` в `main.py`, исправить импорты ролей (`ROLE_USER` и др. не существуют в `roles.py`), добавить эндпоинт `/auth/pass/activate`, убрать дубли импортов (`TTLCache`, `Response`)
|
||||
- Улучшение прокси-режима
|
||||
- Стабильность и производительность серверной части
|
||||
- Общую надёжность загрузки файлов с сервера
|
||||
- аккаунты, проходки
|
||||
|
||||
## Важная информация перед использованием
|
||||
|
||||
@@ -39,12 +52,10 @@
|
||||
|
||||
Лаунчер использует **текстовый интерфейс (TUI)**:
|
||||
|
||||
- `W` / `S` (или `Ц` / `Ы`) — перемещение по меню
|
||||
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
|
||||
- `ENTER` — выбор пункта
|
||||
- `ESC` или пункт «Назад» — возврат назад
|
||||
|
||||
> **Важно:** Стрелки ↑/↓ могут вызывать баги и краши. Используйте только `W`/`S`.
|
||||
|
||||
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
||||
|
||||
### Расположение сборок
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
||||
<fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC Launcher</productName>
|
||||
@@ -91,6 +91,24 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>global</id>
|
||||
<properties>
|
||||
<launcher.title>ZernMC Launcher</launcher.title>
|
||||
<build.profile>global</build.profile>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
<profile>
|
||||
<id>zernmc</id>
|
||||
<properties>
|
||||
<launcher.title>ZernMC Private Launcher</launcher.title>
|
||||
<build.profile>zernmc</build.profile>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
<properties>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||
|
||||
+50
-5
@@ -6,13 +6,16 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>me.sashegdev</groupId>
|
||||
<artifactId>ZernMCLauncher</artifactId>
|
||||
<version>1.0.7</version>
|
||||
<version>1.0.8</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.organization.name>ZernMC</project.organization.name>
|
||||
<project.inceptionYear>2026</project.inceptionYear>
|
||||
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
|
||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||
</properties>
|
||||
|
||||
@@ -42,6 +45,11 @@
|
||||
<artifactId>jansi</artifactId>
|
||||
<version>2.4.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jline</groupId>
|
||||
<artifactId>jline</artifactId>
|
||||
<version>3.24.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.tongfei</groupId>
|
||||
<artifactId>progressbar</artifactId>
|
||||
@@ -52,10 +60,22 @@
|
||||
<artifactId>commons-io</artifactId>
|
||||
<version>2.15.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.10.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
|
||||
<!-- Shade Plugin -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
@@ -76,7 +96,7 @@
|
||||
<Implementation-Version>${project.version}</Implementation-Version>
|
||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||
<Implementation-Vendor>SashegDev</Implementation-Vendor>
|
||||
<Implementation-Description>Полностью самописный Minecraft-лаунчер. Написанный SashegDev(в основном)</Implementation-Description>
|
||||
<Implementation-Description>Samopisnui Minecraft-launcher. by SashegDev</Implementation-Description>
|
||||
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
@@ -99,7 +119,7 @@
|
||||
<goal>launch4j</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
|
||||
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
||||
<headerType>console</headerType>
|
||||
<dontWrapJar>false</dontWrapJar>
|
||||
@@ -110,13 +130,13 @@
|
||||
<versionInfo>
|
||||
<fileVersion>${project.version}.0</fileVersion>
|
||||
<txtFileVersion>${project.version}</txtFileVersion>
|
||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
||||
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
|
||||
<productVersion>${project.version}.0</productVersion>
|
||||
<txtProductVersion>${project.version}</txtProductVersion>
|
||||
<productName>ZernMC Launcher</productName>
|
||||
<companyName>ZernMC(SashegDev)</companyName>
|
||||
<internalName>ZernMCLauncher</internalName>
|
||||
<originalFilename>ZernMCLauncher.exe</originalFilename>
|
||||
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
|
||||
</versionInfo>
|
||||
</configuration>
|
||||
</execution>
|
||||
@@ -153,4 +173,29 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<profiles>
|
||||
<!-- ==================== GLOBAL ==================== -->
|
||||
<profile>
|
||||
<id>global</id>
|
||||
<activation>
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<build.profile>global</build.profile>
|
||||
<launcher.title>ZernMC Launcher</launcher.title>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
<!-- Можно добавить флаги для отключения некоторых фич -->
|
||||
</properties>
|
||||
</profile>
|
||||
|
||||
<!-- ==================== ZERNMC ==================== -->
|
||||
<profile>
|
||||
<id>zernmc</id>
|
||||
<properties>
|
||||
<build.profile>zernmc</build.profile>
|
||||
<launcher.title>ZernMC Private Launcher</launcher.title>
|
||||
<server.url>http://87.120.187.36:1582</server.url>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -4,7 +4,6 @@ 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.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -23,13 +22,12 @@ public class Main {
|
||||
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.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
||||
|
||||
//проверка всех сервисов при старте
|
||||
// Проверка всех сервисов при старте
|
||||
ZHttpClient.checkAllServicesOnStartup();
|
||||
|
||||
checkAndAutoUpdateLauncher();
|
||||
@@ -49,8 +47,8 @@ public class Main {
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
||||
}
|
||||
// === КОНЕЦ АВТОРИЗАЦИИ ===
|
||||
|
||||
// === ГЛАВНЫЙ ЦИКЛ ===
|
||||
try {
|
||||
mainLoop();
|
||||
} catch (Exception e) {
|
||||
@@ -63,7 +61,6 @@ public class Main {
|
||||
|
||||
private static void checkAndAutoUpdateLauncher() {
|
||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
||||
|
||||
try {
|
||||
String json = ZHttpClient.getLauncherVersionInfo();
|
||||
String serverVersion = extractVersion(json);
|
||||
@@ -74,13 +71,11 @@ public class Main {
|
||||
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
|
||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
|
||||
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
|
||||
|
||||
performAutoUpdate(serverVersion);
|
||||
restartLauncher();
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
@@ -109,9 +104,7 @@ public class Main {
|
||||
long size = Files.size(tempJar);
|
||||
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
|
||||
|
||||
// Заменяем текущий jar
|
||||
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
|
||||
}
|
||||
|
||||
@@ -152,27 +145,73 @@ public class Main {
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
|
||||
private static void mainLoop() throws Exception {
|
||||
if (Config.isZernMCBuild()) {
|
||||
zernMCFlow();
|
||||
} else {
|
||||
globalFlow();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ZERNMC FLOW ======================
|
||||
private static void zernMCFlow() throws Exception {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||
|
||||
// 1. Проверка подключения к серверу
|
||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
|
||||
ConsoleUtils.pause();
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
// 2. Авторизация
|
||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
||||
if (!sessionRestored) {
|
||||
LoginMenu loginMenu = new LoginMenu();
|
||||
boolean loggedIn = loginMenu.show();
|
||||
if (!loggedIn) {
|
||||
System.exit(0);
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
||||
}
|
||||
|
||||
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
|
||||
LaunchMenu launchMenu = new LaunchMenu();
|
||||
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
|
||||
}
|
||||
|
||||
// ====================== GLOBAL FLOW ======================
|
||||
private static void globalFlow() throws Exception {
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
||||
|
||||
List<String> options = List.of(
|
||||
"Запустить игру",
|
||||
"Проверка обновлений",
|
||||
"Настройки",
|
||||
"Проверка подключения к серверам Zern",
|
||||
"Выход"
|
||||
"Запустить игру",
|
||||
"Проверка обновлений",
|
||||
"Настройки",
|
||||
"Проверка подключения к серверам",
|
||||
"Выход"
|
||||
);
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
||||
int choice = menu.show();
|
||||
|
||||
if (choice == -1 || choice == 4) {
|
||||
System.out.print("\033[H\033[2J");
|
||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||
break;
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> new LaunchMenu().show();
|
||||
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
|
||||
case 1 -> new UpdateMenu().show();
|
||||
case 2 -> new SettingsMenu().show();
|
||||
case 3 -> new ServerCheckMenu().show();
|
||||
|
||||
@@ -10,9 +10,12 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public class AuthManager {
|
||||
|
||||
@@ -20,6 +23,18 @@ public class AuthManager {
|
||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
private static volatile AuthSession session = null;
|
||||
private static volatile UserInfo userInfo = null;
|
||||
|
||||
// === Роли ===
|
||||
public static final int ROLE_USER = 0;
|
||||
public static final int ROLE_PASS_HOLDER = 1;
|
||||
public static final int ROLE_MODERATOR = 2;
|
||||
public static final int ROLE_ELDER = 3;
|
||||
public static final int ROLE_CREATOR = 4;
|
||||
|
||||
// === Права доступа ===
|
||||
public static final String PERM_VIEW_PACKS = "view_packs";
|
||||
public static final String PERM_DOWNLOAD_PACK = "download_pack";
|
||||
|
||||
public static boolean loadSavedSession() {
|
||||
if (!Files.exists(AUTH_FILE)) return false;
|
||||
@@ -29,6 +44,8 @@ public class AuthManager {
|
||||
if (loaded == null || loaded.accessToken == null) return false;
|
||||
|
||||
session = loaded;
|
||||
userInfo = fetchUserInfo();
|
||||
|
||||
if (isAccessTokenExpired()) {
|
||||
return tryRefresh();
|
||||
}
|
||||
@@ -38,6 +55,7 @@ public class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== АВТОРИЗАЦИЯ ======================
|
||||
public static AuthResult login(String username, String password) {
|
||||
return authRequest("/auth/login", username, password);
|
||||
}
|
||||
@@ -49,17 +67,13 @@ public class AuthManager {
|
||||
private static AuthResult authRequest(String endpoint, String username, String password) {
|
||||
try {
|
||||
String body = GSON.toJson(new LoginRequest(username, password));
|
||||
|
||||
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
|
||||
|
||||
SimpleHttpResponse resp = post(endpoint, body);
|
||||
|
||||
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
|
||||
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
session = GSON.fromJson(resp.body(), AuthSession.class);
|
||||
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
||||
saveSession();
|
||||
userInfo = fetchUserInfo();
|
||||
return AuthResult.ok();
|
||||
} else if (resp.statusCode() == 422) {
|
||||
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
|
||||
@@ -67,7 +81,6 @@ public class AuthManager {
|
||||
return AuthResult.fail(extractError(resp.body()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
|
||||
e.printStackTrace();
|
||||
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
||||
}
|
||||
@@ -75,11 +88,12 @@ public class AuthManager {
|
||||
|
||||
public static void logout() {
|
||||
if (session != null && session.refreshToken != null) {
|
||||
try {
|
||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
||||
try {
|
||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -113,16 +127,18 @@ public class AuthManager {
|
||||
try {
|
||||
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
||||
SimpleHttpResponse 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;
|
||||
userInfo = fetchUserInfo();
|
||||
saveSession();
|
||||
return true;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
session = null;
|
||||
userInfo = null;
|
||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||
return false;
|
||||
}
|
||||
@@ -136,19 +152,82 @@ public class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
|
||||
private static UserInfo fetchUserInfo() {
|
||||
if (!isLoggedIn() || session.accessToken == null) return null;
|
||||
|
||||
try {
|
||||
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
|
||||
java.net.HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
|
||||
conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode != 200) return null;
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (var reader = new java.io.BufferedReader(
|
||||
new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
return GSON.fromJson(response.toString(), UserInfo.class);
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Не удалось получить UserInfo: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== ПРОВЕРКИ ПРАВ ====================
|
||||
public static boolean hasPass() {
|
||||
if (userInfo != null) return userInfo.has_pass;
|
||||
return getRole() >= ROLE_PASS_HOLDER;
|
||||
}
|
||||
|
||||
public static boolean canViewPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_VIEW_PACKS);
|
||||
}
|
||||
return hasPass(); // fallback для старых аккаунтов
|
||||
}
|
||||
|
||||
public static boolean canDownloadPacks() {
|
||||
if (userInfo != null && userInfo.permissions != null) {
|
||||
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
|
||||
}
|
||||
return hasPass(); // fallback
|
||||
}
|
||||
|
||||
public static int getRole() {
|
||||
return session != null ? session.role : ROLE_USER;
|
||||
}
|
||||
|
||||
// ====================== POST ======================
|
||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||
HttpURLConnection conn = null;
|
||||
|
||||
java.net.HttpURLConnection conn = null;
|
||||
try {
|
||||
java.net.URL url = java.net.URI.create(fullUrl).toURL();
|
||||
conn = (java.net.HttpURLConnection) url.openConnection();
|
||||
URL url = new URL(fullUrl);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
||||
conn.setRequestProperty("Connection", "close");
|
||||
|
||||
// Добавляем токен авторизации, если есть сессия
|
||||
if (session != null && session.accessToken != null) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
||||
}
|
||||
@@ -157,19 +236,19 @@ public class AuthManager {
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(15000);
|
||||
|
||||
try (java.io.OutputStream os = conn.getOutputStream()) {
|
||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||
os.write(input, 0, input.length);
|
||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||
conn.setFixedLengthStreamingMode(input.length);
|
||||
|
||||
try (var os = conn.getOutputStream()) {
|
||||
os.write(input);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
int statusCode = conn.getResponseCode();
|
||||
|
||||
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
|
||||
? conn.getInputStream()
|
||||
: conn.getErrorStream();
|
||||
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
|
||||
String responseBody;
|
||||
try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||
}
|
||||
|
||||
@@ -183,19 +262,13 @@ public class AuthManager {
|
||||
private static String extractError(String body) {
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
if (json.has("detail")) {
|
||||
if (json.get("detail").isJsonArray()) {
|
||||
return json.getAsJsonArray("detail").get(0).getAsJsonObject()
|
||||
.get("msg").getAsString();
|
||||
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
|
||||
}
|
||||
return json.get("detail").getAsString();
|
||||
}
|
||||
if (json.has("error")) {
|
||||
return json.get("error").getAsString();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
|
||||
@@ -203,36 +276,27 @@ public class AuthManager {
|
||||
if (!isLoggedIn()) return false;
|
||||
try {
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
return response.contains("\"is_active\":true");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
return json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
System.err.println("Не удалось проверить проходки: " + e.getMessage());
|
||||
System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static String activatePass(String passCode) {
|
||||
public static String getPassStatus() {
|
||||
if (!isLoggedIn()) return "Не авторизован";
|
||||
try {
|
||||
String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}";
|
||||
SimpleHttpResponse resp = post("/auth/pass/activate", json);
|
||||
|
||||
System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode()));
|
||||
|
||||
if (resp.statusCode() == 200) {
|
||||
return "Проходка успешно активирована!";
|
||||
} else if (resp.statusCode() == 401) {
|
||||
return "Ошибка: Требуется авторизация. Перезайдите в аккаунт.";
|
||||
} else {
|
||||
String error = extractError(resp.body());
|
||||
return "Ошибка: " + error;
|
||||
}
|
||||
String response = ZHttpClient.get("/auth/pass/my");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||
return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return "Ошибка соединения: " + e.getMessage();
|
||||
return "Ошибка проверки";
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
||||
|
||||
public static class AuthSession {
|
||||
@SerializedName("access_token") public String accessToken;
|
||||
@SerializedName("refresh_token") public String refreshToken;
|
||||
@@ -240,12 +304,30 @@ public class AuthManager {
|
||||
public transient long expiresAt;
|
||||
public String username;
|
||||
public String uuid;
|
||||
public int role;
|
||||
}
|
||||
|
||||
public static class UserInfo {
|
||||
public int id;
|
||||
public String username;
|
||||
public String uuid;
|
||||
public int role;
|
||||
public String role_name;
|
||||
public boolean has_pass;
|
||||
public List<String> permissions;
|
||||
|
||||
public boolean hasPermission(String perm) {
|
||||
return permissions != null && permissions.contains(perm);
|
||||
}
|
||||
}
|
||||
|
||||
private static class LoginRequest {
|
||||
final String username;
|
||||
final String password;
|
||||
LoginRequest(String u, String p) { this.username = u; this.password = p; }
|
||||
LoginRequest(String u, String p) {
|
||||
this.username = u;
|
||||
this.password = p;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AuthResult {
|
||||
@@ -261,12 +343,12 @@ public class AuthManager {
|
||||
class SimpleHttpResponse {
|
||||
final int statusCode;
|
||||
final String body;
|
||||
|
||||
|
||||
SimpleHttpResponse(int statusCode, String body) {
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
|
||||
int statusCode() { return statusCode; }
|
||||
String body() { return body; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -148,14 +148,54 @@ public class LoginMenu {
|
||||
* Читаем пароль — стараемся скрыть вывод через 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) : "";
|
||||
private String readPassword(String prompt) throws IOException {
|
||||
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
|
||||
.system(true)
|
||||
.jna(true)
|
||||
.build();
|
||||
|
||||
passTerminal.enterRawMode();
|
||||
passTerminal.writer().print(prompt);
|
||||
passTerminal.writer().flush();
|
||||
|
||||
StringBuilder password = new StringBuilder();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
int key = passTerminal.reader().read();
|
||||
|
||||
if (key == 27) {
|
||||
// Escape sequence — consume remaining bytes (arrow keys, etc.)
|
||||
int next = passTerminal.reader().read(50);
|
||||
if (next == 91) { // '[' — arrow key sequence
|
||||
passTerminal.reader().read(50); // consume 'A'/'B'/'C'/'D'
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key == 13 || key == 10) { // Enter
|
||||
passTerminal.writer().println();
|
||||
break;
|
||||
} else if (key == 127 || key == 8) { // Backspace
|
||||
if (password.length() > 0) {
|
||||
password.setLength(password.length() - 1);
|
||||
passTerminal.writer().print("\b \b");
|
||||
passTerminal.writer().flush();
|
||||
}
|
||||
} else if (key == 3) { // Ctrl+C
|
||||
passTerminal.writer().println();
|
||||
System.exit(0);
|
||||
} else if (key >= 32 && key < 127) { // Printable characters
|
||||
password.append((char) key);
|
||||
passTerminal.writer().print('*');
|
||||
passTerminal.writer().flush();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
passTerminal.close();
|
||||
}
|
||||
// Fallback: в IDE пароль будет виден
|
||||
return Input.readLine(prompt);
|
||||
|
||||
return password.toString();
|
||||
}
|
||||
|
||||
private void printBanner() {
|
||||
|
||||
@@ -16,71 +16,83 @@ import java.util.List;
|
||||
public class ServerCheckMenu {
|
||||
|
||||
public void show() throws IOException {
|
||||
List<String> options = List.of(
|
||||
"Проверить подключение к ZernMC серверу",
|
||||
"Проверить доступ к Mojang (Minecraft)",
|
||||
"Проверить доступ к Fabric Meta",
|
||||
"Назад в главное меню"
|
||||
);
|
||||
while (true) {
|
||||
ConsoleUtils.clearScreen();
|
||||
System.out.println(ZAnsi.header("Диагностика подключения"));
|
||||
|
||||
ArrowMenu menu = new ArrowMenu("Диагностика подключения", options);
|
||||
int choice = menu.show();
|
||||
List<String> options = List.of(
|
||||
"Проверить подключение к ZernMC серверу",
|
||||
"Проверить доступ к Mojang (Minecraft)",
|
||||
"Проверить доступ к Fabric Meta",
|
||||
"Проверить доступ к Forge Maven",
|
||||
"Назад в главное меню"
|
||||
);
|
||||
|
||||
if (choice == -1 || choice == 4) return;
|
||||
ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
|
||||
int choice = menu.show();
|
||||
|
||||
ConsoleUtils.clearScreen();
|
||||
if (choice == -1 || choice == 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> checkZernServer();
|
||||
case 1 -> checkMojang();
|
||||
case 2 -> checkFabric();
|
||||
ConsoleUtils.clearScreen();
|
||||
|
||||
switch (choice) {
|
||||
case 0 -> checkZernServer();
|
||||
case 1 -> checkMojang();
|
||||
case 2 -> checkFabric();
|
||||
case 3 -> checkForge();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
ConsoleUtils.pause();
|
||||
}
|
||||
|
||||
private void checkZernServer() {
|
||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||
|
||||
try {
|
||||
String response = ZHttpClient.get("/health");
|
||||
System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!"));
|
||||
System.out.println("Ответ: " + response);
|
||||
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
|
||||
System.out.println(ZAnsi.white("Ответ сервера: ") + response);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println("Ошибка: " + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkMojang() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"))
|
||||
.uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("Mojang доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Нет доступа к Mojang"));
|
||||
System.out.println("Ошибка: " + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkFabric() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
@@ -91,13 +103,39 @@ public class ServerCheckMenu {
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("Fabric Meta доступен"));
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode()));
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta"));
|
||||
System.out.println("Ошибка: " + e.getMessage());
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void checkForge() {
|
||||
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
|
||||
|
||||
try {
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() == 200) {
|
||||
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен"));
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
|
||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ public class Instance {
|
||||
private final Path path;
|
||||
|
||||
private String minecraftVersion;
|
||||
private String loaderType; // vanilla, fabric, forge
|
||||
private String loaderType; // vanilla, fabric, forge, neoforge
|
||||
private String loaderVersion;
|
||||
private String assetIndex;
|
||||
private boolean isServerPack; // флаг, что это сборка с сервера
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.FabricInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.NeoForgeInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
@@ -41,6 +42,11 @@ public class MinecraftLib {
|
||||
return installer.install(minecraftVersion, forgeVersion);
|
||||
}
|
||||
|
||||
public boolean installNeoForge(String minecraftVersion, String neoforgeVersion) throws Exception {
|
||||
NeoForgeInstaller installer = new NeoForgeInstaller(instance);
|
||||
return installer.install(minecraftVersion, neoforgeVersion);
|
||||
}
|
||||
|
||||
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
|
||||
FabricInstaller installer = new FabricInstaller(instance);
|
||||
boolean success = installer.install(minecraftVersion, loaderVersion);
|
||||
@@ -76,8 +82,17 @@ public class MinecraftLib {
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||
System.out.println(ZAnsi.yellow("Forge пока не поддерживается"));
|
||||
return false;
|
||||
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||
if (!forgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить Forge"));
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(loaderType)) {
|
||||
boolean neoforgeInstalled = installNeoForge(minecraftVersion, loaderVersion);
|
||||
if (!neoforgeInstalled) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. В будущем здесь будет diff и скачивание модов
|
||||
@@ -129,7 +144,8 @@ public class MinecraftLib {
|
||||
try (var stream = Files.walk(versionsDir)) {
|
||||
stream.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().contains("fabric-loader") ||
|
||||
dir.getFileName().toString().contains("forge"))
|
||||
dir.getFileName().toString().contains("forge") ||
|
||||
dir.getFileName().toString().contains("neoforge"))
|
||||
.filter(dir -> !dir.getFileName().toString().contains(keepVersion))
|
||||
.forEach(this::safeDeleteDirectory);
|
||||
}
|
||||
@@ -163,6 +179,8 @@ public class MinecraftLib {
|
||||
deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer);
|
||||
} else if ("forge".equals(loaderType)) {
|
||||
deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer);
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer);
|
||||
}
|
||||
|
||||
// Также чистим versions/ от старых fabric/forge версий
|
||||
|
||||
+108
-85
@@ -6,6 +6,8 @@ import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
@@ -27,7 +29,7 @@ public class PackDownloader {
|
||||
private final Instance instance;
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||
//private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
|
||||
|
||||
public PackDownloader(Instance instance) {
|
||||
this.instance = instance;
|
||||
@@ -37,73 +39,89 @@ public class PackDownloader {
|
||||
* Получить список доступных паков с сервера
|
||||
*/
|
||||
public List<ServerPack> getAvailablePacks() throws Exception {
|
||||
String response = ZHttpClient.get("/packs");
|
||||
|
||||
// Для отладки - выведем ответ сервера
|
||||
System.out.println(ZAnsi.cyan("Ответ сервера: " + response));
|
||||
|
||||
JsonObject root = JsonParser.parseString(response).getAsJsonObject();
|
||||
|
||||
// Проверяем, есть ли поле "packs"
|
||||
if (!root.has("packs")) {
|
||||
System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа"));
|
||||
return new ArrayList<>();
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken == null) {
|
||||
throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
|
||||
}
|
||||
|
||||
if (!AuthManager.canViewPacks()) {
|
||||
throw new IOException("Для просмотра сборок требуется активная проходка");
|
||||
}
|
||||
|
||||
// Используем HttpURLConnection для GET с авторизацией
|
||||
java.net.HttpURLConnection connection = null;
|
||||
try {
|
||||
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
|
||||
connection = (java.net.HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
|
||||
if (responseCode == 403) {
|
||||
throw new IOException("Для просмотра сборок требуется активная проходка");
|
||||
}
|
||||
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("HTTP " + responseCode);
|
||||
}
|
||||
|
||||
return parsePacksResponse(response.toString());
|
||||
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ServerPack> parsePacksResponse(String responseBody) {
|
||||
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||
JsonArray packsArray = root.getAsJsonArray("packs");
|
||||
List<ServerPack> result = new ArrayList<>();
|
||||
|
||||
for (JsonElement elem : packsArray) {
|
||||
JsonObject pack = elem.getAsJsonObject();
|
||||
|
||||
// Пропускаем паки с ошибками
|
||||
if (pack.has("error")) {
|
||||
System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString()));
|
||||
|
||||
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Пропускаем паки со статусом not_scanned
|
||||
if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) {
|
||||
System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере"));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Пробуем получить name или pack_name (разные форматы)
|
||||
String name = null;
|
||||
if (pack.has("name")) {
|
||||
name = pack.get("name").getAsString();
|
||||
} else if (pack.has("pack_name")) {
|
||||
name = pack.get("pack_name").getAsString();
|
||||
} else {
|
||||
continue; // Пропускаем если нет имени
|
||||
}
|
||||
|
||||
String name = pack.get("name").getAsString();
|
||||
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
||||
|
||||
// Получаем остальные поля (могут отсутствовать)
|
||||
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
|
||||
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
|
||||
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() ? pack.get("loader_version").getAsString() : "";
|
||||
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
|
||||
? pack.get("loader_version").getAsString() : "";
|
||||
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
|
||||
|
||||
// Парсим дату, если есть
|
||||
|
||||
LocalDateTime updatedAt = null;
|
||||
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
||||
try {
|
||||
updatedAt = parseDateTime(pack.get("updated_at").getAsString());
|
||||
} catch (Exception e) {
|
||||
// Игнорируем ошибки парсинга даты
|
||||
}
|
||||
updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(),
|
||||
DateTimeFormatter.ISO_DATE_TIME);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
result.add(new ServerPack(name, version, minecraftVersion,
|
||||
loaderType, loaderVersion, updatedAt, filesCount));
|
||||
|
||||
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||
loaderVersion, updatedAt, filesCount));
|
||||
} catch (Exception e) {
|
||||
System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage()));
|
||||
System.err.println("Ошибка парсинга пака: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -139,6 +157,12 @@ public class PackDownloader {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||
return false;
|
||||
}
|
||||
} else if ("neoforge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installNeoForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
System.err.println(ZAnsi.brightRed("Не удалось установить NeoForge"));
|
||||
return false;
|
||||
}
|
||||
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||
if (!success) {
|
||||
@@ -292,22 +316,19 @@ public class PackDownloader {
|
||||
*/
|
||||
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
||||
String json = gson.toJson(localFiles);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName));
|
||||
System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт"));
|
||||
System.out.println(ZAnsi.cyan("JSON тело: " + json));
|
||||
|
||||
String baseUrl = ZHttpClient.getBaseUrl();
|
||||
if (baseUrl.endsWith("/")) {
|
||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
||||
|
||||
// Получаем токен авторизации
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken == null) {
|
||||
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
|
||||
}
|
||||
String url = baseUrl + "/pack/" + packName + "/diff";
|
||||
|
||||
System.out.println(ZAnsi.cyan("URL: " + url));
|
||||
|
||||
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding
|
||||
// РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient
|
||||
|
||||
if (!AuthManager.canDownloadPacks()) {
|
||||
throw new IOException("Для скачивания сборок требуется активная проходка");
|
||||
}
|
||||
|
||||
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
|
||||
|
||||
// Используем HttpURLConnection для полного контроля
|
||||
java.net.HttpURLConnection connection = null;
|
||||
try {
|
||||
java.net.URL urlObj = new java.net.URL(url);
|
||||
@@ -315,21 +336,21 @@ public class PackDownloader {
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
|
||||
connection.setDoOutput(true);
|
||||
connection.setConnectTimeout(30000);
|
||||
connection.setReadTimeout(30000);
|
||||
|
||||
|
||||
// Отправляем JSON
|
||||
try (java.io.OutputStream os = connection.getOutputStream()) {
|
||||
byte[] input = json.getBytes("UTF-8");
|
||||
os.write(input, 0, input.length);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
|
||||
|
||||
|
||||
// Читаем ответ
|
||||
StringBuilder response = new StringBuilder();
|
||||
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||
@@ -339,16 +360,19 @@ public class PackDownloader {
|
||||
response.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
String responseBody = response.toString();
|
||||
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
|
||||
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("HTTP " + responseCode + ": " + responseBody);
|
||||
|
||||
if (responseCode == 403) {
|
||||
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
|
||||
}
|
||||
|
||||
|
||||
if (responseCode != 200) {
|
||||
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
|
||||
}
|
||||
|
||||
return gson.fromJson(responseBody, DiffResponse.class);
|
||||
|
||||
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
@@ -356,6 +380,16 @@ public class PackDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
private String extractErrorFromResponse(String body) {
|
||||
try {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
if (json.has("detail")) {
|
||||
return json.get("detail").getAsString();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить diff (скачать новые файлы, удалить старые)
|
||||
*/
|
||||
@@ -486,17 +520,6 @@ public class PackDownloader {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг даты из строки
|
||||
*/
|
||||
private LocalDateTime parseDateTime(String dateTimeStr) {
|
||||
try {
|
||||
return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ====================== Вложенные классы ======================
|
||||
|
||||
public static class PackManifest {
|
||||
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.installer;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class NeoForgeInstaller {
|
||||
|
||||
private final Instance instance;
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(java.time.Duration.ofSeconds(30))
|
||||
.build();
|
||||
|
||||
public NeoForgeInstaller(Instance instance) {
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
public boolean install(String mcVersion, String neoForgeVersion) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Установка NeoForge " + neoForgeVersion + " для Minecraft " + mcVersion));
|
||||
|
||||
System.out.println(ZAnsi.cyan("Установка базовой версии Minecraft " + mcVersion + "..."));
|
||||
VersionInstaller vanillaInstaller = new VersionInstaller(instance.getPath());
|
||||
String assetIndex = vanillaInstaller.install(mcVersion);
|
||||
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
System.out.println(ZAnsi.brightRed("Не удалось установить базовую версию Minecraft"));
|
||||
return false;
|
||||
}
|
||||
|
||||
instance.setAssetIndex(assetIndex);
|
||||
createLauncherProfile();
|
||||
|
||||
String mavenGroup = getMavenGroup(mcVersion);
|
||||
String mavenArtifact = getMavenArtifact(mcVersion);
|
||||
|
||||
String installerUrl = "https://maven.neoforged.net/releases/"
|
||||
+ mavenGroup.replace('.', '/') + "/"
|
||||
+ mavenArtifact + "/"
|
||||
+ neoForgeVersion
|
||||
+ "/" + mavenArtifact + "-" + neoForgeVersion + "-installer.jar";
|
||||
|
||||
Path installerJar = instance.getPath().resolve("neoforge-installer.jar");
|
||||
|
||||
System.out.println(ZAnsi.cyan("Скачивание NeoForge Installer..."));
|
||||
downloadFileWithProgress(installerUrl, installerJar);
|
||||
|
||||
System.out.println(ZAnsi.cyan("Запуск NeoForge Installer..."));
|
||||
System.out.println(ZAnsi.yellow("Это может занять несколько минут. Пожалуйста, подождите...\n"));
|
||||
|
||||
boolean success = runNeoForgeInstaller(installerJar);
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
downloadMissingLibraries(mcVersion, neoForgeVersion, mavenGroup, mavenArtifact);
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Предупреждение: не удалось докачать некоторые библиотеки: " + e.getMessage()));
|
||||
}
|
||||
|
||||
System.out.println(ZAnsi.brightGreen("\nNeoForge " + neoForgeVersion + " успешно установлен!"));
|
||||
instance.setMinecraftVersion(mcVersion);
|
||||
instance.setLoaderType("neoforge");
|
||||
instance.setLoaderVersion(neoForgeVersion);
|
||||
|
||||
Files.deleteIfExists(installerJar);
|
||||
return true;
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("\nОшибка при установке NeoForge!"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String getMavenGroup(String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return "net.neoforged";
|
||||
}
|
||||
return "net.neoforged";
|
||||
}
|
||||
|
||||
private String getMavenArtifact(String mcVersion) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return "forge";
|
||||
}
|
||||
return "neoforge";
|
||||
}
|
||||
|
||||
private void createLauncherProfile() throws IOException {
|
||||
Path profilePath = instance.getPath().resolve("launcher_profiles.json");
|
||||
if (Files.exists(profilePath)) return;
|
||||
|
||||
String minimalProfile = """
|
||||
{
|
||||
"profiles": {},
|
||||
"selectedProfile": "Default"
|
||||
}
|
||||
""";
|
||||
Files.writeString(profilePath, minimalProfile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
System.out.println(ZAnsi.yellow("Создан launcher_profiles.json"));
|
||||
}
|
||||
|
||||
private void downloadFileWithProgress(String url, Path target) throws Exception {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("Content-Length").orElse(-1);
|
||||
|
||||
try (InputStream in = response.body();
|
||||
FileOutputStream out = new FileOutputStream(target.toFile())) {
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
long totalRead = 0;
|
||||
int lastPercent = -1;
|
||||
|
||||
while ((bytesRead = in.read(buffer)) != -1) {
|
||||
out.write(buffer, 0, bytesRead);
|
||||
totalRead += bytesRead;
|
||||
|
||||
if (contentLength > 0) {
|
||||
int percent = (int) ((totalRead * 100) / contentLength);
|
||||
if (percent != lastPercent) {
|
||||
String downloaded = ProgressBar.formatBytes(totalRead);
|
||||
String total = ProgressBar.formatBytes(contentLength);
|
||||
ProgressBar.show("NeoForge Installer", percent, 100, "% (" + downloaded + "/" + total + ")");
|
||||
lastPercent = percent;
|
||||
}
|
||||
} else {
|
||||
char[] spinner = {'|', '/', '-', '\\'};
|
||||
int idx = (int) (totalRead / 1024) % 4;
|
||||
System.out.print("\rСкачивание NeoForge Installer: " + ProgressBar.formatBytes(totalRead) + " " + spinner[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProgressBar.finish("NeoForge Installer (" + ProgressBar.formatBytes(Files.size(target)) + ")");
|
||||
}
|
||||
|
||||
private boolean runNeoForgeInstaller(Path installerJar) throws IOException, InterruptedException {
|
||||
int maxRetries = 3;
|
||||
int attempt = 1;
|
||||
|
||||
while (attempt <= maxRetries) {
|
||||
System.out.println(ZAnsi.cyan("Попытка " + attempt + " из " + maxRetries));
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"java",
|
||||
"-jar",
|
||||
installerJar.toAbsolutePath().toString(),
|
||||
"--installClient"
|
||||
);
|
||||
|
||||
pb.environment().put("JAVA_OPTS", "-Dhttp.connectionTimeout=60000 -Dhttp.socketTimeout=60000");
|
||||
pb.directory(instance.getPath().toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
Process process = pb.start();
|
||||
|
||||
StringBuilder output = new StringBuilder();
|
||||
boolean hasErrors = false;
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
output.append(line).append("\n");
|
||||
|
||||
if (line.contains("Downloading") || line.contains("Extracting")) {
|
||||
System.out.println(ZAnsi.blue(" -> " + line));
|
||||
} else if (line.contains("SUCCESS") || line.contains("successfully")) {
|
||||
System.out.println(ZAnsi.brightGreen(" + " + line));
|
||||
} else if (line.contains("WARNING") || line.contains("warning")) {
|
||||
System.out.println(ZAnsi.yellow(" ! " + line));
|
||||
} else if (line.contains("ERROR") || line.contains("error") || line.contains("failed") || line.contains("timed out")) {
|
||||
System.out.println(ZAnsi.brightRed(" X " + line));
|
||||
if (line.contains("timed out") || line.contains("failed to download")) {
|
||||
hasErrors = true;
|
||||
}
|
||||
} else if (!line.isBlank()) {
|
||||
System.out.println(" " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exitCode = process.waitFor();
|
||||
|
||||
if (exitCode == 0 && !hasErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
System.out.println(ZAnsi.yellow("Ошибка при установке. Повторная попытка через 5 секунд..."));
|
||||
Thread.sleep(5000);
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().contains("asm") && p.toString().endsWith(".jar"))
|
||||
.forEach(p -> {
|
||||
try { Files.deleteIfExists(p); }
|
||||
catch (IOException e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
System.out.println(ZAnsi.brightRed("NeoForge Installer завершился с кодом ошибки: " + exitCode));
|
||||
|
||||
if (output.toString().contains("timed out")) {
|
||||
System.out.println(ZAnsi.yellow("\nВозможные решения:"));
|
||||
System.out.println(ZAnsi.yellow("1. Проверьте интернет-соединение"));
|
||||
System.out.println(ZAnsi.yellow("2. Запустите лаунчер от имени администратора"));
|
||||
System.out.println(ZAnsi.yellow("3. Временно отключите антивирус/брандмауэр"));
|
||||
System.out.println(ZAnsi.yellow("4. Попробуйте установить другую версию NeoForge"));
|
||||
}
|
||||
}
|
||||
|
||||
attempt++;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void downloadMissingLibraries(String mcVersion, String neoForgeVersion, String mavenGroup, String mavenArtifact) throws Exception {
|
||||
System.out.println(ZAnsi.cyan("Проверка и докачка отсутствующих библиотек..."));
|
||||
|
||||
Map<String, String> alternativeUrls = new HashMap<>();
|
||||
alternativeUrls.put("org/ow2/asm/asm/9.6/asm-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm-commons/9.6/asm-commons-9.6.jar");
|
||||
alternativeUrls.put("org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar",
|
||||
"https://repo1.maven.org/maven2/org/ow2/asm/asm-tree/9.6/asm-tree-9.6.jar");
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
for (Map.Entry<String, String> entry : alternativeUrls.entrySet()) {
|
||||
Path target = librariesDir.resolve(entry.getKey());
|
||||
if (!Files.exists(target)) {
|
||||
Files.createDirectories(target.getParent());
|
||||
System.out.println(ZAnsi.yellow("Докачка: " + target.getFileName()));
|
||||
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
downloadFileWithProgress(entry.getValue(), target);
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (attempt == 3) throw e;
|
||||
System.out.println(ZAnsi.yellow("Повторная попытка " + attempt + "/3..."));
|
||||
Thread.sleep(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+325
-272
@@ -3,11 +3,14 @@ package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class LaunchCommandBuilder {
|
||||
|
||||
@@ -22,105 +25,266 @@ public class LaunchCommandBuilder {
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
|
||||
// 1. Путь к Java
|
||||
String javaPath = getJavaPath();
|
||||
String javaPath = "java";
|
||||
command.add(javaPath);
|
||||
|
||||
// 2. JVM аргументы
|
||||
command.addAll(getJvmArguments(options));
|
||||
|
||||
// 3. Natives
|
||||
Path nativesDir = instance.getPath().resolve("natives");
|
||||
if (!Files.exists(nativesDir)) {
|
||||
Files.createDirectories(nativesDir);
|
||||
}
|
||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("forge".equals(loaderType)) {
|
||||
command.addAll(getForgeJvmArguments());
|
||||
VersionManifest manifest = resolveVersionManifest();
|
||||
if (manifest != null) {
|
||||
command.add("-cp");
|
||||
command.add(buildForgeClasspath());
|
||||
command.add("cpw.mods.modlauncher.Launcher");
|
||||
command.addAll(getForgeArguments(options));
|
||||
command.add(buildClasspathFromManifest(manifest));
|
||||
|
||||
String mainClass = resolveMainClass(manifest);
|
||||
command.add(mainClass);
|
||||
|
||||
command.addAll(resolveGameArguments(manifest, options));
|
||||
} else {
|
||||
command.add("-cp");
|
||||
command.add(buildClasspath());
|
||||
command.add(getMainClass());
|
||||
command.addAll(getMinecraftArguments(options));
|
||||
command.add(buildVanillaClasspath());
|
||||
command.add(getVanillaMainClass());
|
||||
command.addAll(getVanillaGameArguments(options));
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private String getJavaPath() {
|
||||
return "java";
|
||||
private VersionManifest resolveVersionManifest() {
|
||||
try {
|
||||
Path versionJson = findVersionJson();
|
||||
if (versionJson != null && Files.exists(versionJson)) {
|
||||
String content = Files.readString(versionJson);
|
||||
JSONObject json = new JSONObject(content);
|
||||
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
|
||||
return new VersionManifest(json);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> getJvmArguments(LaunchOptions options) {
|
||||
List<String> jvmArgs = new ArrayList<>();
|
||||
private Path findVersionJson() {
|
||||
Path versionsDir = instance.getPath().resolve("versions");
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String loaderVersion = instance.getLoaderVersion();
|
||||
|
||||
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
||||
jvmArgs.add("-Xmx" + ramMB + "M");
|
||||
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
|
||||
String[] candidates = {
|
||||
getVersionId(),
|
||||
mcVersion + "-" + loaderType + "-" + loaderVersion,
|
||||
loaderType + "-" + loaderVersion,
|
||||
mcVersion + "-" + loaderVersion,
|
||||
mcVersion
|
||||
};
|
||||
for (String candidate : candidates) {
|
||||
Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json");
|
||||
if (Files.exists(jsonPath)) {
|
||||
return jsonPath;
|
||||
}
|
||||
}
|
||||
|
||||
jvmArgs.add("-XX:+UseG1GC");
|
||||
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
||||
jvmArgs.add("-XX:G1NewSizePercent=20");
|
||||
jvmArgs.add("-XX:G1ReservePercent=20");
|
||||
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
||||
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
||||
try {
|
||||
if (Files.exists(versionsDir)) {
|
||||
try (var stream = Files.list(versionsDir)) {
|
||||
return stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||
dir.getFileName().toString().contains("neoforge"))
|
||||
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
|
||||
.findFirst()
|
||||
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
|
||||
.filter(Files::exists)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json");
|
||||
if (Files.exists(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getVersionId() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String loaderVer = instance.getLoaderVersion();
|
||||
|
||||
if ("vanilla".equals(loaderType)) {
|
||||
return mcVersion;
|
||||
}
|
||||
else if ("fabric".equals(loaderType)) {
|
||||
String fabricId = instance.getFabricVersionId();
|
||||
if (fabricId != null && !fabricId.isEmpty()) {
|
||||
return fabricId;
|
||||
}
|
||||
return "fabric-loader-" + loaderVer + "-" + mcVersion;
|
||||
}
|
||||
else if ("forge".equals(loaderType)) {
|
||||
return mcVersion + "-forge-" + loaderVer;
|
||||
}
|
||||
else if ("neoforge".equals(loaderType)) {
|
||||
if (mcVersion.equals("1.20.1")) {
|
||||
return mcVersion + "-neoforge-" + loaderVer;
|
||||
}
|
||||
return "neoforge-" + loaderVer;
|
||||
}
|
||||
|
||||
return mcVersion;
|
||||
}
|
||||
|
||||
private String resolveMainClass(VersionManifest manifest) {
|
||||
return manifest.getMainClass();
|
||||
}
|
||||
|
||||
private String getVanillaMainClass() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
if ("fabric".equals(loaderType)) {
|
||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
||||
}
|
||||
return "net.minecraft.client.main.Main";
|
||||
}
|
||||
|
||||
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
Map<String, String> vars = buildVariableMap(options);
|
||||
|
||||
for (String raw : manifest.getGameArguments()) {
|
||||
args.add(resolveVariable(raw, vars));
|
||||
}
|
||||
|
||||
if (options.getWidth() > 0) {
|
||||
args.add("--width");
|
||||
args.add(String.valueOf(options.getWidth()));
|
||||
}
|
||||
if (options.getHeight() > 0) {
|
||||
args.add("--height");
|
||||
args.add(String.valueOf(options.getHeight()));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private List<String> getVanillaGameArguments(LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
args.add("--version");
|
||||
args.add(instance.getName());
|
||||
args.add("--gameDir");
|
||||
args.add(instance.getPath().toAbsolutePath().toString());
|
||||
args.add("--assetsDir");
|
||||
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||
args.add("--assetIndex");
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
|
||||
}
|
||||
args.add(assetIndex);
|
||||
args.add("--username");
|
||||
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||
args.add("--accessToken");
|
||||
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
args.add("--uuid");
|
||||
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
args.add("--userType");
|
||||
args.add("legacy");
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private Map<String, String> buildVariableMap(LaunchOptions options) {
|
||||
Map<String, String> vars = new HashMap<>();
|
||||
|
||||
Path gameDir = instance.getPath().toAbsolutePath();
|
||||
Path assetsDir = gameDir.resolve("assets");
|
||||
Path nativesDir = gameDir.resolve("natives");
|
||||
Path librariesDir = gameDir.resolve("libraries");
|
||||
|
||||
vars.put("version_name", instance.getName());
|
||||
vars.put("game_directory", gameDir.toString());
|
||||
vars.put("assets_root", assetsDir.toString());
|
||||
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
|
||||
vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player");
|
||||
vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
vars.put("auth_xuid", "0");
|
||||
vars.put("user_type", "legacy");
|
||||
vars.put("version_type", "release");
|
||||
vars.put("natives_directory", nativesDir.toString());
|
||||
vars.put("library_directory", librariesDir.toString());
|
||||
vars.put("launcher_name", "ZernMC");
|
||||
vars.put("launcher_version", "1.0");
|
||||
vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":");
|
||||
vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920));
|
||||
vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080));
|
||||
vars.put("game_directory", gameDir.toString());
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
if ("forge".equals(loaderType)) {
|
||||
vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||
vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||
vars.put("fml.neoForgeGroup", "net.neoforged");
|
||||
}
|
||||
|
||||
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
||||
jvmArgs.addAll(options.getExtraJvmArgs());
|
||||
return vars;
|
||||
}
|
||||
|
||||
private String resolveVariable(String raw, Map<String, String> vars) {
|
||||
if (!raw.contains("${")) return raw;
|
||||
String result = raw;
|
||||
for (Map.Entry<String, String> entry : vars.entrySet()) {
|
||||
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
|
||||
}
|
||||
|
||||
return jvmArgs;
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<String> getForgeJvmArguments() {
|
||||
List<String> jvmArgs = new ArrayList<>();
|
||||
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
|
||||
jvmArgs.add("-Dforge.logging.console.level=debug");
|
||||
jvmArgs.add("-Dforge.logging.mojang.level=info");
|
||||
jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-");
|
||||
jvmArgs.add("-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar");
|
||||
|
||||
return jvmArgs;
|
||||
}
|
||||
|
||||
private String buildClasspath() throws Exception {
|
||||
private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
|
||||
for (VersionManifest.Library lib : manifest.getLibraries()) {
|
||||
Path libPath = librariesDir.resolve(lib.artifactPath);
|
||||
if (Files.exists(libPath)) {
|
||||
paths.add(libPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
String mavenPath = mavenToPath(lib.name);
|
||||
Path fallbackPath = librariesDir.resolve(mavenPath);
|
||||
if (Files.exists(fallbackPath)) {
|
||||
paths.add(fallbackPath.toAbsolutePath().toString());
|
||||
} else {
|
||||
System.out.println(ZAnsi.yellow(" Библиотека не найдена: " + lib.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Path versionJar = findVersionJar();
|
||||
if (versionJar != null) {
|
||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||
return String.join(separator, paths);
|
||||
}
|
||||
|
||||
private String buildVanillaClasspath() throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
String versionId = getVersionId();
|
||||
|
||||
Path versionJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(versionId)
|
||||
@@ -152,222 +316,111 @@ public class LaunchCommandBuilder {
|
||||
return String.join(separator, paths);
|
||||
}
|
||||
|
||||
private String buildForgeClasspath() throws Exception {
|
||||
List<String> paths = new ArrayList<>();
|
||||
|
||||
private Path findVersionJar() {
|
||||
String versionId = getVersionId();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String forgeVersion = instance.getLoaderVersion();
|
||||
Path versionsDir = instance.getPath().resolve("versions");
|
||||
|
||||
Path librariesDir = instance.getPath().resolve("libraries");
|
||||
if (Files.exists(librariesDir)) {
|
||||
try (var stream = Files.walk(librariesDir)) {
|
||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
||||
.map(p -> p.toAbsolutePath().toString())
|
||||
.forEach(paths::add);
|
||||
Path[] candidates = {
|
||||
versionsDir.resolve(versionId).resolve(versionId + ".jar"),
|
||||
versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar")
|
||||
};
|
||||
|
||||
for (Path candidate : candidates) {
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
Path versionJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(versionId)
|
||||
.resolve(versionId + ".jar");
|
||||
if (Files.exists(versionJar)) {
|
||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||
} else {
|
||||
Path vanillaJar = instance.getPath()
|
||||
.resolve("versions")
|
||||
.resolve(mcVersion)
|
||||
.resolve(mcVersion + ".jar");
|
||||
if (Files.exists(vanillaJar)) {
|
||||
paths.add(0, vanillaJar.toAbsolutePath().toString());
|
||||
try {
|
||||
if (Files.exists(versionsDir)) {
|
||||
try (var stream = Files.list(versionsDir)) {
|
||||
return stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||
dir.getFileName().toString().contains("neoforge"))
|
||||
.filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion()))
|
||||
.findFirst()
|
||||
.map(dir -> dir.resolve(dir.getFileName().toString() + ".jar"))
|
||||
.filter(Files::exists)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
|
||||
Path forgeUniversal = instance.getPath()
|
||||
.resolve("libraries")
|
||||
.resolve("net")
|
||||
.resolve("minecraftforge")
|
||||
.resolve("forge")
|
||||
.resolve(mcVersion + "-" + forgeVersion)
|
||||
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-universal.jar");
|
||||
if (Files.exists(forgeUniversal)) {
|
||||
paths.add(forgeUniversal.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
Path forgeClient = instance.getPath()
|
||||
.resolve("libraries")
|
||||
.resolve("net")
|
||||
.resolve("minecraftforge")
|
||||
.resolve("forge")
|
||||
.resolve(mcVersion + "-" + forgeVersion)
|
||||
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-client.jar");
|
||||
if (Files.exists(forgeClient)) {
|
||||
paths.add(forgeClient.toAbsolutePath().toString());
|
||||
}
|
||||
|
||||
String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"};
|
||||
for (String module : forgeModules) {
|
||||
Path modulePath = instance.getPath()
|
||||
.resolve("libraries")
|
||||
.resolve("net")
|
||||
.resolve("minecraftforge")
|
||||
.resolve(module)
|
||||
.resolve(mcVersion + "-" + forgeVersion)
|
||||
.resolve(module + "-" + mcVersion + "-" + forgeVersion + ".jar");
|
||||
if (Files.exists(modulePath)) {
|
||||
paths.add(modulePath.toAbsolutePath().toString());
|
||||
}
|
||||
}
|
||||
|
||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||
return String.join(separator, paths);
|
||||
return null;
|
||||
}
|
||||
|
||||
private String getMainClass() {
|
||||
private String mavenToPath(String mavenName) {
|
||||
String[] parts = mavenName.split(":");
|
||||
if (parts.length < 3) return mavenName;
|
||||
|
||||
String group = parts[0].replace('.', '/');
|
||||
String artifact = parts[1];
|
||||
String version = parts[2];
|
||||
|
||||
if (parts.length == 4) {
|
||||
String classifier = parts[3];
|
||||
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar";
|
||||
}
|
||||
|
||||
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar";
|
||||
}
|
||||
|
||||
private List<String> getJvmArguments(LaunchOptions options) {
|
||||
List<String> jvmArgs = new ArrayList<>();
|
||||
|
||||
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
||||
jvmArgs.add("-Xmx" + ramMB + "M");
|
||||
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||
|
||||
jvmArgs.add("-XX:+UseG1GC");
|
||||
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
||||
jvmArgs.add("-XX:G1NewSizePercent=20");
|
||||
jvmArgs.add("-XX:G1ReservePercent=20");
|
||||
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
||||
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
||||
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
|
||||
if ("fabric".equals(loaderType)) {
|
||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
||||
}
|
||||
else if ("forge".equals(loaderType)) {
|
||||
return "cpw.mods.modlauncher.Launcher";
|
||||
}
|
||||
else {
|
||||
return "net.minecraft.client.main.Main";
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
} else if ("forge".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
} else if ("neoforge".equals(loaderType)) {
|
||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||
}
|
||||
|
||||
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
||||
jvmArgs.addAll(options.getExtraJvmArgs());
|
||||
}
|
||||
|
||||
return jvmArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* ИСПРАВЛЕНО: используем instance.getAssetIndex() вместо minecraftVersion
|
||||
*/
|
||||
private List<String> getMinecraftArguments(LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
args.add("--version");
|
||||
args.add(instance.getName());
|
||||
|
||||
args.add("--gameDir");
|
||||
args.add(instance.getPath().toAbsolutePath().toString());
|
||||
|
||||
args.add("--assetsDir");
|
||||
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||
|
||||
// FIXED: Используем правильный assetIndex
|
||||
args.add("--assetIndex");
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
|
||||
} else {
|
||||
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
|
||||
}
|
||||
args.add(assetIndex);
|
||||
|
||||
args.add("--username");
|
||||
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||
|
||||
args.add("--accessToken");
|
||||
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
|
||||
args.add("--uuid");
|
||||
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
|
||||
args.add("--userType");
|
||||
args.add("legacy");
|
||||
|
||||
if (options.getWidth() > 0) {
|
||||
args.add("--width");
|
||||
args.add(String.valueOf(options.getWidth()));
|
||||
}
|
||||
if (options.getHeight() > 0) {
|
||||
args.add("--height");
|
||||
args.add(String.valueOf(options.getHeight()));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* ИСПРАВЛЕНО: для Forge тоже используем правильный assetIndex
|
||||
*/
|
||||
private List<String> getForgeArguments(LaunchOptions options) {
|
||||
List<String> args = new ArrayList<>();
|
||||
|
||||
args.add("--launchTarget");
|
||||
args.add("forgeclient");
|
||||
|
||||
args.add("--fml.forgeVersion");
|
||||
args.add(instance.getLoaderVersion());
|
||||
|
||||
args.add("--fml.mcVersion");
|
||||
args.add(instance.getMinecraftVersion());
|
||||
|
||||
args.add("--fml.forgeGroup");
|
||||
args.add("net.minecraftforge");
|
||||
|
||||
args.add("--gameDir");
|
||||
args.add(instance.getPath().toAbsolutePath().toString());
|
||||
|
||||
args.add("--assetsDir");
|
||||
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||
|
||||
// FIXED: Используем правильный assetIndex для Forge
|
||||
args.add("--assetIndex");
|
||||
String assetIndex = instance.getAssetIndex();
|
||||
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||
assetIndex = instance.getMinecraftVersion();
|
||||
}
|
||||
args.add(assetIndex);
|
||||
|
||||
args.add("--username");
|
||||
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||
|
||||
args.add("--accessToken");
|
||||
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||
|
||||
args.add("--uuid");
|
||||
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||
|
||||
args.add("--userType");
|
||||
args.add("legacy");
|
||||
|
||||
if (options.getWidth() > 0) {
|
||||
args.add("--width");
|
||||
args.add(String.valueOf(options.getWidth()));
|
||||
}
|
||||
if (options.getHeight() > 0) {
|
||||
args.add("--height");
|
||||
args.add(String.valueOf(options.getHeight()));
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* ИСПРАВЛЕНО: для Fabric используем сохраненный fabricVersionId
|
||||
*/
|
||||
private String getVersionId() {
|
||||
String loaderType = instance.getLoaderType().toLowerCase();
|
||||
String mcVersion = instance.getMinecraftVersion();
|
||||
String loaderVer = instance.getLoaderVersion();
|
||||
|
||||
if ("vanilla".equals(loaderType)) {
|
||||
return mcVersion;
|
||||
}
|
||||
else if ("fabric".equals(loaderType)) {
|
||||
// Используем сохраненный fabricVersionId если есть
|
||||
String fabricId = instance.getFabricVersionId();
|
||||
if (fabricId != null && !fabricId.isEmpty()) {
|
||||
return fabricId;
|
||||
}
|
||||
// fallback
|
||||
return "fabric-loader-" + loaderVer + "-" + mcVersion;
|
||||
}
|
||||
else if ("forge".equals(loaderType)) {
|
||||
return mcVersion + "-forge-" + loaderVer;
|
||||
}
|
||||
|
||||
return mcVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class VersionManifest {
|
||||
|
||||
private final String id;
|
||||
private final String mainClass;
|
||||
private final String assetIndexId;
|
||||
private final List<String> jvmArguments;
|
||||
private final List<String> gameArguments;
|
||||
private final List<Library> libraries;
|
||||
|
||||
public VersionManifest(JSONObject json) {
|
||||
this.id = json.getString("id");
|
||||
this.mainClass = json.getString("mainClass");
|
||||
|
||||
if (json.has("assetIndex")) {
|
||||
JSONObject ai = json.getJSONObject("assetIndex");
|
||||
this.assetIndexId = ai.has("id") ? ai.getString("id") : "unknown";
|
||||
} else {
|
||||
this.assetIndexId = "unknown";
|
||||
}
|
||||
|
||||
this.jvmArguments = parseArguments(json, "jvm");
|
||||
this.gameArguments = parseArguments(json, "game");
|
||||
this.libraries = parseLibraries(json);
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getMainClass() { return mainClass; }
|
||||
public String getAssetIndexId() { return assetIndexId; }
|
||||
public List<String> getJvmArguments() { return jvmArguments; }
|
||||
public List<String> getGameArguments() { return gameArguments; }
|
||||
public List<Library> getLibraries() { return libraries; }
|
||||
|
||||
private List<String> parseArguments(JSONObject json, String type) {
|
||||
List<String> args = new ArrayList<>();
|
||||
if (!json.has("arguments")) return args;
|
||||
|
||||
JSONObject arguments = json.getJSONObject("arguments");
|
||||
if (!arguments.has(type)) return args;
|
||||
|
||||
JSONArray arr = arguments.getJSONArray(type);
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
Object item = arr.get(i);
|
||||
if (item instanceof String) {
|
||||
args.add((String) item);
|
||||
} else if (item instanceof JSONObject) {
|
||||
JSONObject ruleObj = (JSONObject) item;
|
||||
if (ruleMatches(ruleObj)) {
|
||||
Object value = ruleObj.get("value");
|
||||
if (value instanceof String) {
|
||||
args.add((String) value);
|
||||
} else if (value instanceof JSONArray) {
|
||||
JSONArray valArr = (JSONArray) value;
|
||||
for (int j = 0; j < valArr.length(); j++) {
|
||||
args.add(valArr.getString(j));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
private boolean ruleMatches(JSONObject ruleObj) {
|
||||
JSONArray rules = ruleObj.getJSONArray("rules");
|
||||
boolean result = false;
|
||||
for (int i = 0; i < rules.length(); i++) {
|
||||
JSONObject rule = rules.getJSONObject(i);
|
||||
String action = rule.getString("action");
|
||||
boolean matches = true;
|
||||
|
||||
if (rule.has("os")) {
|
||||
JSONObject os = rule.getJSONObject("os");
|
||||
String osName = System.getProperty("os.name").toLowerCase();
|
||||
if (os.has("name")) {
|
||||
String reqName = os.getString("name").toLowerCase();
|
||||
if (reqName.equals("windows") && !osName.contains("win")) matches = false;
|
||||
else if (reqName.equals("linux") && !osName.contains("linux") && !osName.contains("nix")) matches = false;
|
||||
else if (reqName.equals("osx") && !osName.contains("mac")) matches = false;
|
||||
}
|
||||
if (os.has("arch")) {
|
||||
String reqArch = os.getString("arch");
|
||||
String osArch = System.getProperty("os.arch");
|
||||
if (!reqArch.equals(osArch)) matches = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.has("features")) {
|
||||
JSONObject features = rule.getJSONObject("features");
|
||||
for (String key : features.keySet()) {
|
||||
if (key.startsWith("is_demo_user") || key.startsWith("has_custom_resolution")) continue;
|
||||
matches = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ("allow".equals(action) && matches) {
|
||||
result = true;
|
||||
} else if ("disallow".equals(action) && matches) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Library> parseLibraries(JSONObject json) {
|
||||
List<Library> libs = new ArrayList<>();
|
||||
if (!json.has("libraries")) return libs;
|
||||
|
||||
JSONArray arr = json.getJSONArray("libraries");
|
||||
for (int i = 0; i < arr.length(); i++) {
|
||||
JSONObject libJson = arr.getJSONObject(i);
|
||||
if (libJson.has("downloads") && libJson.getJSONObject("downloads").has("artifact")) {
|
||||
String name = libJson.getString("name");
|
||||
String artifactPath = libJson.getJSONObject("downloads").getJSONObject("artifact").getString("path");
|
||||
Library lib = new Library(name, artifactPath);
|
||||
|
||||
if (libJson.has("natives")) {
|
||||
JSONObject natives = libJson.getJSONObject("natives");
|
||||
for (String key : natives.keySet()) {
|
||||
String osKey = key.toLowerCase();
|
||||
lib.natives.put(osKey, natives.getString(key));
|
||||
}
|
||||
}
|
||||
|
||||
if (libJson.has("rules")) {
|
||||
JSONObject dummyObj = new JSONObject();
|
||||
dummyObj.put("rules", libJson.getJSONArray("rules"));
|
||||
dummyObj.put("value", "");
|
||||
if (ruleMatches(dummyObj)) {
|
||||
libs.add(lib);
|
||||
}
|
||||
} else {
|
||||
libs.add(lib);
|
||||
}
|
||||
}
|
||||
}
|
||||
return libs;
|
||||
}
|
||||
|
||||
public static class Library {
|
||||
public final String name;
|
||||
public final String artifactPath;
|
||||
public final Map<String, String> natives = new HashMap<>();
|
||||
|
||||
public Library(String name, String artifactPath) {
|
||||
this.name = name;
|
||||
this.artifactPath = artifactPath;
|
||||
}
|
||||
|
||||
public String getSimpleName() {
|
||||
return name.substring(name.indexOf(':') + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,30 @@ public class ArrowMenu {
|
||||
printPagedMenu();
|
||||
int key = terminal.reader().read();
|
||||
|
||||
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц') { // Up
|
||||
if (key == 'w' || key == 'W' || key == 'ц' || key == 'Ц'
|
||||
|| key == 'k' || key == 'K' || key == 'л' || key == 'Л') { // Up / Arrow Up
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
}
|
||||
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы') { // Down
|
||||
}
|
||||
else if (key == 's' || key == 'S' || key == 'ы' || key == 'Ы'
|
||||
|| key == 'j' || key == 'J' || key == 'о' || key == 'О') { // Down / Arrow Down
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
}
|
||||
else if (key == 13 || key == 10) { // Enter
|
||||
return selected;
|
||||
}
|
||||
else if (key == 27) { // Esc
|
||||
return -1;
|
||||
}
|
||||
else if (key == 27) { // Esc or arrow escape seq
|
||||
int next = terminal.reader().read(50);
|
||||
if (next == 91) { // '[' — start of arrow escape sequence
|
||||
int arrow = terminal.reader().read(50);
|
||||
if (arrow == 65) { // 'A' — Up arrow
|
||||
selected = (selected - 1 + options.size()) % options.size();
|
||||
} else if (arrow == 66) { // 'B' — Down arrow
|
||||
selected = (selected + 1) % options.size();
|
||||
}
|
||||
// else — unknown escape seq, ignore
|
||||
} else {
|
||||
return -1; // genuine Esc
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -83,7 +96,7 @@ public class ArrowMenu {
|
||||
|
||||
// Подсказка внизу (фиксированная)
|
||||
sb.append("\n")
|
||||
.append(ZAnsi.white("W/S (Ц/Ы) - перемещение | Enter - выбрать | Esc - назад"));
|
||||
.append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
|
||||
|
||||
System.out.print(sb);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public class Config {
|
||||
private static final Path CONFIG_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
|
||||
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties");
|
||||
|
||||
private static final String BUILD_PROFILE = System.getProperty("build.profile", "global");
|
||||
|
||||
private static final Properties props = new Properties();
|
||||
|
||||
// Настройки
|
||||
@@ -83,6 +85,14 @@ public class Config {
|
||||
return maxMemory;
|
||||
}
|
||||
|
||||
public static boolean isZernMCBuild() {
|
||||
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
|
||||
}
|
||||
|
||||
public static boolean isGlobalBuild() {
|
||||
return !isZernMCBuild();
|
||||
}
|
||||
|
||||
public static void setMaxMemory(int memory) {
|
||||
// Защита от слишком маленьких/больших значений
|
||||
if (memory < 1024) memory = 1536;
|
||||
|
||||
@@ -19,6 +19,7 @@ public class Input {
|
||||
}
|
||||
|
||||
public static String readLine(String prompt) {
|
||||
flushInput(); // Очищаем буфер
|
||||
System.out.print(prompt);
|
||||
return scanner.nextLine().trim();
|
||||
}
|
||||
@@ -79,4 +80,18 @@ public class Input {
|
||||
public static void close() {
|
||||
scanner.close();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Очищает буфер ввода от оставшихся символов
|
||||
*/
|
||||
public static void flushInput() {
|
||||
try {
|
||||
while (System.in.available() > 0) {
|
||||
System.in.read();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Игнорируем
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ public class ZAnsi {
|
||||
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightCyan(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String yellow(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
@@ -47,6 +51,27 @@ public class ZAnsi {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightBlue(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String magenta(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String brightMagenta(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// Пурпурный как brightPurple (используем magenta)
|
||||
public static String purple(String text) {
|
||||
return brightMagenta(text);
|
||||
}
|
||||
|
||||
public static String brightPurple(String text) {
|
||||
return brightMagenta(text);
|
||||
}
|
||||
|
||||
public static String white(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
||||
}
|
||||
@@ -55,7 +80,28 @@ public class ZAnsi {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// Стили
|
||||
public static String black(String text) {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// === Фоновые цвета ===
|
||||
public static String bgGreen(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String bgRed(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String bgYellow(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String bgBlue(String text) {
|
||||
return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// === Стили ===
|
||||
public static String bold(String text) {
|
||||
return Ansi.ansi().bold().a(text).reset().toString();
|
||||
}
|
||||
@@ -64,17 +110,73 @@ public class ZAnsi {
|
||||
return Ansi.ansi().reset().toString();
|
||||
}
|
||||
|
||||
// Комбинированные удобные методы
|
||||
// === Комбинированные удобные методы ===
|
||||
public static String header(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
||||
}
|
||||
|
||||
public static String success(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String error(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String warning(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String info(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString();
|
||||
}
|
||||
|
||||
public static String selected(String text) {
|
||||
return Ansi.ansi()
|
||||
.bgBright(Ansi.Color.WHITE)
|
||||
.fgBlack()
|
||||
.fg(Ansi.Color.BLACK)
|
||||
.bold()
|
||||
.a(" > " + text + " ")
|
||||
.reset()
|
||||
.toString();
|
||||
}
|
||||
|
||||
public static String dim(String text) {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString();
|
||||
}
|
||||
|
||||
// === Цветной текст для ролей ===
|
||||
public static String roleUser(String text) {
|
||||
return white(text);
|
||||
}
|
||||
|
||||
public static String rolePassHolder(String text) {
|
||||
return brightGreen(text);
|
||||
}
|
||||
|
||||
public static String roleModerator(String text) {
|
||||
return brightBlue(text);
|
||||
}
|
||||
|
||||
public static String roleElder(String text) {
|
||||
return brightPurple(text);
|
||||
}
|
||||
|
||||
public static String roleCreator(String text) {
|
||||
return brightRed(text);
|
||||
}
|
||||
|
||||
// === Очистка экрана ===
|
||||
public static String clearScreen() {
|
||||
return Ansi.ansi().eraseScreen().cursor(1, 1).toString();
|
||||
}
|
||||
|
||||
// === Прогресс бар символы ===
|
||||
public static String progressChar() {
|
||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString();
|
||||
}
|
||||
|
||||
public static String progressEmpty() {
|
||||
return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package me.sashegdev.zernmc.launcher.utils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
@@ -25,20 +27,33 @@ public class ZHttpClient {
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
|
||||
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||
private static 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);
|
||||
|
||||
/**
|
||||
* Переопределить URL сервера (для тестов).
|
||||
* Внимание: не потокобезопасно, использовать только в тестах.
|
||||
*/
|
||||
public static void setBaseUrl(String url) {
|
||||
BASE_URL = url;
|
||||
}
|
||||
|
||||
public static String getBaseUrl() {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
// Умное проксирование по сервисам
|
||||
public enum ServiceType {
|
||||
ZERN_SERVER(BASE_URL, true),
|
||||
ZERN_SERVER("http://87.120.187.36:1582", true),
|
||||
FABRIC_META("https://meta.fabricmc.net", false),
|
||||
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||
MOJANG_RESOURCES("https://resources.download.minecraft.net", false),
|
||||
FORGE_MAVEN("https://maven.minecraftforge.net", false),
|
||||
NEOFORGE_MAVEN("https://maven.neoforged.net", false),
|
||||
GOOGLE("https://google.com", false),
|
||||
CLOUDFLARE("https://cloudflare.com", false);
|
||||
|
||||
@@ -92,7 +107,8 @@ public class ZHttpClient {
|
||||
ServiceType.FABRIC_MAVEN,
|
||||
ServiceType.MOJANG_META,
|
||||
ServiceType.MOJANG_RESOURCES,
|
||||
ServiceType.FORGE_MAVEN
|
||||
ServiceType.FORGE_MAVEN,
|
||||
ServiceType.NEOFORGE_MAVEN
|
||||
);
|
||||
|
||||
for (ServiceType service : servicesToCheck) {
|
||||
@@ -223,6 +239,7 @@ public class ZHttpClient {
|
||||
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;
|
||||
if (url.contains("maven.neoforged.net")) return ServiceType.NEOFORGE_MAVEN;
|
||||
if (url.contains("google.com")) return ServiceType.GOOGLE;
|
||||
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
|
||||
return null;
|
||||
@@ -380,13 +397,19 @@ public class ZHttpClient {
|
||||
}
|
||||
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(BASE_URL + endpoint))
|
||||
.timeout(Duration.ofSeconds(15))
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
.GET();
|
||||
|
||||
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
HttpRequest request = requestBuilder.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
@@ -401,19 +424,25 @@ public class ZHttpClient {
|
||||
|
||||
private static String proxyGet(String endpoint) throws IOException {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
.GET();
|
||||
|
||||
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
|
||||
String accessToken = AuthManager.getAccessToken();
|
||||
if (accessToken != null && !accessToken.equals("0")) {
|
||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
||||
}
|
||||
|
||||
HttpRequest request = requestBuilder.build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
|
||||
if (response.statusCode() != 200) {
|
||||
throw new IOException("HTTP " + response.statusCode());
|
||||
}
|
||||
|
||||
|
||||
proxySuccessCount++;
|
||||
return response.body();
|
||||
} catch (Exception e) {
|
||||
@@ -479,10 +508,6 @@ public class ZHttpClient {
|
||||
|
||||
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
|
||||
|
||||
public static String getBaseUrl() {
|
||||
return BASE_URL;
|
||||
}
|
||||
|
||||
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
|
||||
return get("/launcher/version");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package me.sashegdev.zernmc.launcher.auth;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for AuthManager error extraction and response parsing.
|
||||
* Tests the contract between server error responses and Java client parsing.
|
||||
*/
|
||||
class AuthManagerParsingTest {
|
||||
|
||||
@Test
|
||||
void extractError_simpleStringDetail() {
|
||||
// Server: raise HTTPException(401, "Неверное имя пользователя или пароль")
|
||||
String body = "{\"detail\":\"Неверное имя пользователя или пароль\"}";
|
||||
String error = extractError(body);
|
||||
assertEquals("Неверное имя пользователя или пароль", error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractError_validationErrorArray() {
|
||||
// FastAPI 422: {"detail": [{"loc": ["body", "username"], "msg": "...", "type": "..."}]}
|
||||
String body = "{" +
|
||||
"\"detail\":[" +
|
||||
"{\"loc\":[\"body\",\"username\"],\"msg\":\"String should have at least 3 characters\",\"type\":\"string_too_short\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
String error = extractError(body);
|
||||
assertEquals("String should have at least 3 characters", error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractError_multipleValidationErrors_returnsFirst() {
|
||||
String body = "{" +
|
||||
"\"detail\":[" +
|
||||
"{\"loc\":[\"body\",\"username\"],\"msg\":\"Username error\",\"type\":\"value_error\"}," +
|
||||
"{\"loc\":[\"body\",\"password\"],\"msg\":\"Password error\",\"type\":\"value_error\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
String error = extractError(body);
|
||||
assertEquals("Username error", error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractError_plainTextBody() {
|
||||
// Non-JSON error body
|
||||
String body = "Internal Server Error";
|
||||
String error = extractError(body);
|
||||
assertEquals("Internal Server Error", error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractError_longBody_truncated() {
|
||||
String longBody = "A".repeat(300);
|
||||
String error = extractError(longBody);
|
||||
assertEquals(203, error.length()); // 200 + "..."
|
||||
assertTrue(error.endsWith("..."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractError_emptyDetail() {
|
||||
String body = "{\"detail\":\"\"}";
|
||||
String error = extractError(body);
|
||||
assertEquals("", error);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractError_noDetailField_returnsBody() {
|
||||
String body = "{\"error\":\"something went wrong\"}";
|
||||
String error = extractError(body);
|
||||
assertEquals("{\"error\":\"something went wrong\"}", error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates AuthManager.extractError() logic for testing.
|
||||
* If this passes, the real method in AuthManager works correctly.
|
||||
*/
|
||||
private static String extractError(String body) {
|
||||
try {
|
||||
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(body).getAsJsonObject();
|
||||
if (json.has("detail")) {
|
||||
if (json.get("detail").isJsonArray()) {
|
||||
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
|
||||
}
|
||||
return json.get("detail").getAsString();
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||
}
|
||||
}
|
||||
+469
@@ -0,0 +1,469 @@
|
||||
package me.sashegdev.zernmc.launcher.integration;
|
||||
|
||||
import org.junit.jupiter.api.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||
|
||||
/**
|
||||
* Integration tests: real Java client ↔ real Python server.
|
||||
*
|
||||
* These tests:
|
||||
* 1. Start the FastAPI test server via Python subprocess
|
||||
* 2. Use actual Java HTTP client code to make requests
|
||||
* 3. Verify JSON parsing and response handling
|
||||
*
|
||||
* Requires: Python 3, pytest, and the server/.venv to be available.
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ServerIntegrationTest {
|
||||
|
||||
private static Process serverProcess;
|
||||
private static String serverBaseUrl;
|
||||
private static Path testDir;
|
||||
private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
@BeforeAll
|
||||
static void startTestServer() throws Exception {
|
||||
// Create temp directory for test data
|
||||
testDir = Files.createTempDirectory("zern_integration_test_");
|
||||
|
||||
// Find the server directory
|
||||
String serverDir = findServerDir();
|
||||
if (serverDir == null) {
|
||||
System.out.println("WARNING: Server directory not found, skipping integration tests");
|
||||
serverBaseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the test server on a random port
|
||||
int port = findFreePort();
|
||||
serverBaseUrl = "http://127.0.0.1:" + port;
|
||||
|
||||
System.out.println("Starting test server on " + serverBaseUrl);
|
||||
System.out.println("Server directory: " + serverDir);
|
||||
|
||||
// Find Python executable (prefer venv python)
|
||||
String pythonPath = findPythonPath(serverDir);
|
||||
if (pythonPath == null) {
|
||||
System.out.println("WARNING: Python not found, skipping integration tests");
|
||||
serverBaseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a Python startup script that properly sets up paths
|
||||
String startupScript =
|
||||
"import sys, os, tempfile\n" +
|
||||
"from pathlib import Path\n" +
|
||||
"sys.path.insert(0, '" + serverDir + "')\n" +
|
||||
"os.chdir('" + serverDir + "')\n" +
|
||||
"import auth\n" +
|
||||
"db_dir = tempfile.mkdtemp()\n" +
|
||||
"auth.AUTH_DB = Path(db_dir) / 'auth.db'\n" +
|
||||
"auth.SECRET_KEY = Path(db_dir) / '.secret_key'\n" +
|
||||
"auth.init_db()\n" +
|
||||
"import uvicorn\n" +
|
||||
"import main\n" +
|
||||
"uvicorn.run(main.app, host='127.0.0.1', port=" + port + ", log_level='error')\n";
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(pythonPath, "-c", startupScript);
|
||||
pb.directory(new File(serverDir));
|
||||
pb.redirectErrorStream(true);
|
||||
|
||||
try {
|
||||
serverProcess = pb.start();
|
||||
System.out.println("Server process started, PID: " + serverProcess.pid());
|
||||
} catch (IOException e) {
|
||||
System.out.println("WARNING: Could not start server process: " + e.getMessage());
|
||||
System.out.println("Skipping integration tests");
|
||||
serverBaseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for server to start
|
||||
Thread.sleep(4000);
|
||||
|
||||
// Verify server is running
|
||||
try {
|
||||
URL url = new URL(serverBaseUrl + "/health");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.connect();
|
||||
if (conn.getResponseCode() != 200) {
|
||||
System.out.println("WARNING: Server health check failed: " + conn.getResponseCode());
|
||||
System.out.println("Skipping integration tests");
|
||||
serverBaseUrl = null;
|
||||
if (serverProcess != null) serverProcess.destroy();
|
||||
conn.disconnect();
|
||||
return;
|
||||
}
|
||||
conn.disconnect();
|
||||
System.out.println("Test server started successfully");
|
||||
} catch (Exception e) {
|
||||
System.out.println("WARNING: Server failed to start: " + e.getMessage());
|
||||
System.out.println("Skipping integration tests");
|
||||
serverBaseUrl = null;
|
||||
if (serverProcess != null) serverProcess.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopTestServer() {
|
||||
if (serverProcess != null) {
|
||||
serverProcess.destroy();
|
||||
try {
|
||||
serverProcess.waitFor(5000, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ignored) {}
|
||||
}
|
||||
// Cleanup temp dir
|
||||
if (testDir != null) {
|
||||
try {
|
||||
Files.walk(testDir)
|
||||
.sorted(java.util.Comparator.reverseOrder())
|
||||
.forEach(path -> {
|
||||
try { Files.delete(path); } catch (IOException ignored) {}
|
||||
});
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
if (serverBaseUrl != null) {
|
||||
ZHttpClient.setBaseUrl(serverBaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Auth flow tests =====
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void testRegister() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
String response = httpPost("/auth/register", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"IntegrationTest123\"" +
|
||||
"}");
|
||||
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
assertTrue(json.has("access_token"));
|
||||
assertTrue(json.has("refresh_token"));
|
||||
assertTrue(json.has("expires_in"));
|
||||
assertTrue(json.has("uuid"));
|
||||
assertEquals("integration_test_user", json.get("username").getAsString());
|
||||
assertTrue(json.has("role"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void testLogin() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
String response = httpPost("/auth/login", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"IntegrationTest123\"" +
|
||||
"}");
|
||||
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
assertTrue(json.has("access_token"));
|
||||
assertTrue(json.has("refresh_token"));
|
||||
assertEquals("integration_test_user", json.get("username").getAsString());
|
||||
assertTrue(json.has("role"));
|
||||
assertTrue(json.has("uuid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void testDuplicateRegistration() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
try {
|
||||
httpPost("/auth/register", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"AnotherPassword123\"" +
|
||||
"}");
|
||||
fail("Should have thrown IOException for duplicate registration");
|
||||
} catch (IOException e) {
|
||||
assertTrue(e.getMessage().contains("409") || e.getMessage().contains("409"),
|
||||
"Expected 409 conflict, got: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void testLoginWrongPassword() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
try {
|
||||
httpPost("/auth/login", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"WrongPassword\"" +
|
||||
"}");
|
||||
fail("Should have thrown IOException for wrong password");
|
||||
} catch (IOException e) {
|
||||
assertTrue(e.getMessage().contains("401"),
|
||||
"Expected 401, got: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void testGetAdminMe() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
// Login to get token
|
||||
String loginResp = httpPost("/auth/login", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"IntegrationTest123\"" +
|
||||
"}");
|
||||
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
|
||||
String token = loginJson.get("access_token").getAsString();
|
||||
|
||||
// Get user info
|
||||
String response = httpGet("/admin/me", token);
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
|
||||
assertTrue(json.has("id"));
|
||||
assertEquals("integration_test_user", json.get("username").getAsString());
|
||||
assertTrue(json.has("uuid"));
|
||||
assertTrue(json.has("role"));
|
||||
assertTrue(json.has("role_name"));
|
||||
assertTrue(json.has("has_pass"));
|
||||
assertTrue(json.has("permissions"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void testValidateToken() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
String loginResp = httpPost("/auth/login", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"IntegrationTest123\"" +
|
||||
"}");
|
||||
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
|
||||
String token = loginJson.get("access_token").getAsString();
|
||||
String uuid = loginJson.get("uuid").getAsString();
|
||||
|
||||
// Validate
|
||||
String response = httpPost("/auth/validate",
|
||||
"{\"username\":\"integration_test_user\",\"uuid\":\"" + uuid + "\"}",
|
||||
token);
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
|
||||
assertTrue(json.has("valid"));
|
||||
assertTrue(json.get("valid").getAsBoolean());
|
||||
assertEquals("integration_test_user", json.get("username").getAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(7)
|
||||
void testRefreshToken() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
String loginResp = httpPost("/auth/login", "{" +
|
||||
"\"username\":\"integration_test_user\"," +
|
||||
"\"password\":\"IntegrationTest123\"" +
|
||||
"}");
|
||||
JsonObject loginJson = JsonParser.parseString(loginResp).getAsJsonObject();
|
||||
String refreshToken = loginJson.get("refresh_token").getAsString();
|
||||
|
||||
// Refresh
|
||||
String response = httpPost("/auth/refresh",
|
||||
"{\"refresh_token\":\"" + refreshToken + "\"}");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
|
||||
assertTrue(json.has("access_token"));
|
||||
assertTrue(json.has("refresh_token"));
|
||||
assertTrue(json.has("expires_in"));
|
||||
assertEquals("integration_test_user", json.get("username").getAsString());
|
||||
}
|
||||
|
||||
// ===== Pack endpoint tests =====
|
||||
|
||||
@Test
|
||||
@Order(8)
|
||||
void testPacksNoAuth() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
try {
|
||||
httpGet("/packs");
|
||||
fail("Should have thrown IOException for unauthenticated access");
|
||||
} catch (IOException e) {
|
||||
assertTrue(e.getMessage().contains("401") || e.getMessage().contains("403"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(9)
|
||||
void testPackManifestPublic() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
// /pack/{name} is public
|
||||
try {
|
||||
String response = httpGet("/pack/nonexistent-pack");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
fail("Should have thrown IOException for non-existent pack");
|
||||
} catch (IOException e) {
|
||||
assertTrue(e.getMessage().contains("404"),
|
||||
"Expected 404, got: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(10)
|
||||
void testLauncherVersion() throws Exception {
|
||||
assumeServerRunning();
|
||||
|
||||
String response = httpGet("/launcher/version");
|
||||
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||
assertTrue(json.has("version") || json.has("latest"));
|
||||
}
|
||||
|
||||
// ===== Helper methods =====
|
||||
|
||||
private static void assumeServerRunning() {
|
||||
org.junit.jupiter.api.Assumptions.assumeTrue(
|
||||
serverBaseUrl != null && serverProcess != null && serverProcess.isAlive(),
|
||||
"Test server is not running"
|
||||
);
|
||||
}
|
||||
|
||||
private static String httpPost(String endpoint, String body) throws IOException {
|
||||
return httpPost(endpoint, body, null);
|
||||
}
|
||||
|
||||
private static String httpPost(String endpoint, String body, String token) throws IOException {
|
||||
URL url = new URL(serverBaseUrl + endpoint);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
if (token != null) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
}
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
|
||||
byte[] input = body.getBytes(StandardCharsets.UTF_8);
|
||||
conn.setFixedLengthStreamingMode(input.length);
|
||||
try (var os = conn.getOutputStream()) {
|
||||
os.write(input);
|
||||
}
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
String response = readResponse(conn, code);
|
||||
|
||||
if (code >= 400) {
|
||||
throw new IOException("HTTP " + code + ": " + response);
|
||||
}
|
||||
|
||||
conn.disconnect();
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String httpGet(String endpoint) throws IOException {
|
||||
return httpGet(endpoint, null);
|
||||
}
|
||||
|
||||
private static String httpGet(String endpoint, String token) throws IOException {
|
||||
URL url = new URL(serverBaseUrl + endpoint);
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
if (token != null) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
}
|
||||
conn.setConnectTimeout(10000);
|
||||
conn.setReadTimeout(10000);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
String response = readResponse(conn, code);
|
||||
|
||||
if (code >= 400) {
|
||||
throw new IOException("HTTP " + code + ": " + response);
|
||||
}
|
||||
|
||||
conn.disconnect();
|
||||
return response;
|
||||
}
|
||||
|
||||
private static String readResponse(HttpURLConnection conn, int code) throws IOException {
|
||||
var is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||
if (is == null) {
|
||||
return "";
|
||||
}
|
||||
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
return scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String findPythonPath(String serverDir) {
|
||||
String[] paths = {
|
||||
serverDir + "/.venv/bin/python3",
|
||||
serverDir + "/.venv/bin/python",
|
||||
"python3",
|
||||
"python"
|
||||
};
|
||||
for (String path : paths) {
|
||||
File f = new File(path);
|
||||
if (f.exists() && f.canExecute()) {
|
||||
return path;
|
||||
}
|
||||
// Try which command
|
||||
try {
|
||||
Process p = new ProcessBuilder(path, "--version").start();
|
||||
int exit = p.waitFor();
|
||||
if (exit == 0) return path;
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String findServerDir() {
|
||||
String[] paths = {
|
||||
"../server",
|
||||
"server",
|
||||
System.getenv("SERVER_DIR")
|
||||
};
|
||||
for (String path : paths) {
|
||||
if (path != null && new File(path).exists() && new File(path, "main.py").exists()) {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int findFreePort() throws IOException {
|
||||
try (java.net.ServerSocket socket = new java.net.ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readProcessOutput() throws IOException {
|
||||
if (serverProcess == null) return "";
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(serverProcess.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line).append("\n");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
package me.sashegdev.zernmc.launcher.minecraft;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for PackDownloader JSON parsing.
|
||||
* Tests that the Java client correctly parses server JSON responses.
|
||||
*/
|
||||
class PackDownloaderParsingTest {
|
||||
|
||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||
|
||||
// ===== /packs response parsing =====
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_singlePack() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{" +
|
||||
"\"name\":\"test-modpack\"," +
|
||||
"\"version\":3," +
|
||||
"\"files_count\":15," +
|
||||
"\"updated_at\":\"2024-01-15T10:30:00\"," +
|
||||
"\"minecraft_version\":\"1.20.4\"," +
|
||||
"\"loader_type\":\"fabric\"," +
|
||||
"\"loader_version\":\"0.15.6\"" +
|
||||
"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(1, packs.size());
|
||||
|
||||
ServerPack pack = packs.get(0);
|
||||
assertEquals("test-modpack", pack.getName());
|
||||
assertEquals(3, pack.getVersion());
|
||||
assertEquals(15, pack.getFilesCount());
|
||||
assertEquals("1.20.4", pack.getMinecraftVersion());
|
||||
assertEquals("fabric", pack.getLoaderType());
|
||||
assertEquals("0.15.6", pack.getLoaderVersion());
|
||||
assertNotNull(pack.getUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_multiplePacks() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{\"name\":\"survival\",\"version\":1,\"files_count\":5,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
|
||||
"{\"name\":\"pvp\",\"version\":10,\"files_count\":50,\"minecraft_version\":\"1.20.4\",\"loader_type\":\"fabric\",\"loader_version\":\"0.15.6\",\"updated_at\":\"2024-02-01T00:00:00\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(2, packs.size());
|
||||
assertEquals("survival", packs.get(0).getName());
|
||||
assertEquals("pvp", packs.get(1).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_skipsErroredPacks() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{\"name\":\"good-pack\",\"version\":1,\"files_count\":1,\"minecraft_version\":\"1.20.1\",\"loader_type\":\"vanilla\",\"loader_version\":null,\"updated_at\":null}," +
|
||||
"{\"name\":\"bad-pack\",\"error\":\"scan failed\"}," +
|
||||
"{\"name\":\"not-scanned\",\"status\":\"not_scanned\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(1, packs.size());
|
||||
assertEquals("good-pack", packs.get(0).getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_missingFields_defaults() {
|
||||
String body = "{" +
|
||||
"\"packs\":[" +
|
||||
"{\"name\":\"minimal-pack\"}" +
|
||||
"]" +
|
||||
"}";
|
||||
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertEquals(1, packs.size());
|
||||
|
||||
ServerPack pack = packs.get(0);
|
||||
assertEquals("minimal-pack", pack.getName());
|
||||
assertEquals(0, pack.getVersion()); // default
|
||||
assertEquals("unknown", pack.getMinecraftVersion()); // default
|
||||
assertEquals("vanilla", pack.getLoaderType()); // default
|
||||
assertEquals("", pack.getLoaderVersion()); // default
|
||||
assertEquals(0, pack.getFilesCount()); // default
|
||||
assertNull(pack.getUpdatedAt()); // default
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePacksResponse_emptyList() {
|
||||
String body = "{\"packs\":[]}";
|
||||
List<ServerPack> packs = parsePacksResponse(body);
|
||||
assertTrue(packs.isEmpty());
|
||||
}
|
||||
|
||||
// ===== PackManifest parsing =====
|
||||
|
||||
@Test
|
||||
void parsePackManifest_withFiles() {
|
||||
String body = "{" +
|
||||
"\"pack_name\":\"my-pack\"," +
|
||||
"\"version\":5," +
|
||||
"\"minecraft_version\":\"1.20.4\"," +
|
||||
"\"loader_type\":\"fabric\"," +
|
||||
"\"loader_version\":\"0.15.6\"," +
|
||||
"\"asset_index\":\"1.20.4\"," +
|
||||
"\"files\":{" +
|
||||
"\"mods/sodium.jar\":{\"path\":\"mods/sodium.jar\",\"url\":\"/pack/my-pack/file/mods/sodium.jar\",\"size\":1024000,\"hash\":\"abc123\"}," +
|
||||
"\"mods/fabric-api.jar\":{\"path\":\"mods/fabric-api.jar\",\"url\":\"/pack/my-pack/file/mods/fabric-api.jar\",\"size\":2048000,\"hash\":\"def456\"}" +
|
||||
"}" +
|
||||
"}";
|
||||
|
||||
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
|
||||
|
||||
assertEquals("my-pack", manifest.getPackName());
|
||||
assertEquals(5, manifest.getVersion());
|
||||
assertEquals("1.20.4", manifest.getMinecraftVersion());
|
||||
assertEquals("fabric", manifest.getLoaderType());
|
||||
assertEquals("0.15.6", manifest.getLoaderVersion());
|
||||
assertEquals("1.20.4", manifest.getAssetIndex());
|
||||
assertFalse(manifest.isEmpty());
|
||||
assertEquals(2, manifest.getFiles().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePackManifest_nullAssetIndex_defaultsToMinecraftVersion() {
|
||||
String body = "{" +
|
||||
"\"pack_name\":\"no-asset\"," +
|
||||
"\"version\":1," +
|
||||
"\"minecraft_version\":\"1.19.4\"," +
|
||||
"\"loader_type\":\"vanilla\"," +
|
||||
"\"loader_version\":null" +
|
||||
"}";
|
||||
|
||||
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
|
||||
assertEquals("1.19.4", manifest.getAssetIndex()); // defaults to minecraft_version
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsePackManifest_noFiles_isEmpty() {
|
||||
String body = "{" +
|
||||
"\"pack_name\":\"empty-pack\"," +
|
||||
"\"version\":1," +
|
||||
"\"minecraft_version\":\"1.20.1\"," +
|
||||
"\"loader_type\":\"vanilla\"," +
|
||||
"\"loader_version\":null" +
|
||||
"}";
|
||||
|
||||
PackDownloader.PackManifest manifest = gson.fromJson(body, PackDownloader.PackManifest.class);
|
||||
assertTrue(manifest.isEmpty());
|
||||
}
|
||||
|
||||
// ===== DiffResponse parsing =====
|
||||
|
||||
@Test
|
||||
void parseDiffResponse_allFields() {
|
||||
String body = "{" +
|
||||
"\"version\":6," +
|
||||
"\"to_download\":[" +
|
||||
"{\"path\":\"mods/new-mod.jar\",\"url\":\"/pack/test/file/mods/new-mod.jar\",\"size\":512000,\"hash\":\"aaa111\"}" +
|
||||
"]," +
|
||||
"\"to_delete\":[\"mods/old-mod.jar\"]," +
|
||||
"\"to_update\":[\"mods/updated-mod.jar\"]" +
|
||||
"}";
|
||||
|
||||
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
|
||||
|
||||
assertEquals(6, diff.getVersion());
|
||||
assertEquals(1, diff.getToDownload().size());
|
||||
assertEquals(1, diff.getToDelete().size());
|
||||
assertEquals(1, diff.getToUpdate().size());
|
||||
|
||||
PackDownloader.FileInfo fileInfo = diff.getToDownload().get(0);
|
||||
assertEquals("mods/new-mod.jar", fileInfo.getPath());
|
||||
assertEquals("/pack/test/file/mods/new-mod.jar", fileInfo.getUrl());
|
||||
assertEquals(512000, fileInfo.getSize());
|
||||
assertEquals("aaa111", fileInfo.getHash());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseDiffResponse_emptyArrays() {
|
||||
String body = "{" +
|
||||
"\"version\":1," +
|
||||
"\"to_download\":[]," +
|
||||
"\"to_delete\":[]," +
|
||||
"\"to_update\":[]" +
|
||||
"}";
|
||||
|
||||
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
|
||||
assertTrue(diff.getToDownload().isEmpty());
|
||||
assertTrue(diff.getToDelete().isEmpty());
|
||||
assertTrue(diff.getToUpdate().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parseDiffResponse_nullArrays_returnsEmpty() {
|
||||
String body = "{\"version\":1}";
|
||||
|
||||
PackDownloader.DiffResponse diff = gson.fromJson(body, PackDownloader.DiffResponse.class);
|
||||
assertNotNull(diff.getToDownload());
|
||||
assertNotNull(diff.getToDelete());
|
||||
assertNotNull(diff.getToUpdate());
|
||||
assertTrue(diff.getToDownload().isEmpty());
|
||||
assertTrue(diff.getToDelete().isEmpty());
|
||||
}
|
||||
|
||||
// ===== ServerPack toString =====
|
||||
|
||||
@Test
|
||||
void serverPack_toString_withDate() {
|
||||
java.time.LocalDateTime date = java.time.LocalDateTime.of(2024, 3, 15, 12, 0);
|
||||
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", date, 25);
|
||||
|
||||
String str = pack.toString();
|
||||
assertTrue(str.contains("my-pack"));
|
||||
assertTrue(str.contains("1.20.4"));
|
||||
assertTrue(str.contains("fabric"));
|
||||
assertTrue(str.contains("25 файлов"));
|
||||
assertTrue(str.contains("15.03.2024"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void serverPack_toString_withoutDate() {
|
||||
ServerPack pack = new ServerPack("my-pack", 2, "1.20.4", "fabric", "0.15.6", null, 25);
|
||||
|
||||
String str = pack.toString();
|
||||
assertTrue(str.contains("my-pack"));
|
||||
assertTrue(str.contains("25 файлов"));
|
||||
assertFalse(str.contains("обновлен"));
|
||||
}
|
||||
|
||||
// ===== Helper: replicates PackDownloader.parsePacksResponse() =====
|
||||
|
||||
private static List<ServerPack> parsePacksResponse(String responseBody) {
|
||||
JsonObject root = com.google.gson.JsonParser.parseString(responseBody).getAsJsonObject();
|
||||
JsonArray packsArray = root.getAsJsonArray("packs");
|
||||
List<ServerPack> result = new ArrayList<>();
|
||||
|
||||
for (var elem : packsArray) {
|
||||
JsonObject pack = elem.getAsJsonObject();
|
||||
|
||||
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String name = pack.get("name").getAsString();
|
||||
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
||||
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
|
||||
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
|
||||
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
|
||||
? pack.get("loader_version").getAsString() : "";
|
||||
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
|
||||
|
||||
java.time.LocalDateTime updatedAt = null;
|
||||
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
||||
try {
|
||||
updatedAt = java.time.LocalDateTime.parse(pack.get("updated_at").getAsString(),
|
||||
java.time.format.DateTimeFormatter.ISO_DATE_TIME);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||
loaderVersion, updatedAt, filesCount));
|
||||
} catch (Exception e) {
|
||||
System.err.println("Ошибка парсинга пака: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
# admin_router.py
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
import structlog
|
||||
import time
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from auth import get_db, require_role, log_audit, get_current_user
|
||||
from roles import (
|
||||
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
|
||||
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# ====================== МОДЕЛИ ======================
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
user_id: int
|
||||
role: int = Field(..., ge=0, le=4)
|
||||
|
||||
class PassRequest(BaseModel):
|
||||
username: str
|
||||
reason: Optional[str] = None
|
||||
|
||||
class PassDecision(BaseModel):
|
||||
request_id: int
|
||||
approved: bool
|
||||
reason: Optional[str] = None
|
||||
|
||||
class CreatePassDirectRequest(BaseModel):
|
||||
username: str
|
||||
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
max_uses: int = Field(1, ge=1, le=10)
|
||||
|
||||
class BanUserRequest(BaseModel):
|
||||
user_id: int
|
||||
days: int = Field(..., ge=1, le=365)
|
||||
reason: str
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ======================
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
search: Optional[str] = None
|
||||
):
|
||||
"""Список пользователей (модераторы видят всех, но без sensitive данных)"""
|
||||
with get_db() as conn:
|
||||
query = "SELECT id, username, uuid, role, created_at, last_login, is_active"
|
||||
params = []
|
||||
|
||||
if current_user["role"] < ROLE_ELDER:
|
||||
# Модераторы не видят забаненных
|
||||
query += " FROM users WHERE is_active = 1"
|
||||
else:
|
||||
query += " FROM users"
|
||||
|
||||
if search:
|
||||
query += " AND (username LIKE ? OR email LIKE ?)"
|
||||
params.extend([f"%{search}%", f"%{search}%"])
|
||||
|
||||
query += " ORDER BY role DESC, username"
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
users = []
|
||||
for row in rows:
|
||||
user_data = {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
}
|
||||
|
||||
# Elder и Creator видят больше информации
|
||||
if current_user["role"] >= ROLE_ELDER:
|
||||
user_data["is_active"] = row["is_active"]
|
||||
# Получаем информацию о проходке
|
||||
pass_info = 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 = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
LIMIT 1
|
||||
""", (row["id"], time.time())).fetchone()
|
||||
|
||||
if pass_info:
|
||||
user_data["has_pass"] = True
|
||||
user_data["pass_expires"] = pass_info["expires_at"]
|
||||
|
||||
users.append(user_data)
|
||||
|
||||
return {"users": users, "total": len(users)}
|
||||
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def get_user_detail(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR))
|
||||
):
|
||||
"""Детальная информация о пользователе"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
|
||||
FROM users WHERE id = ?
|
||||
""", (user_id,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Модераторы не видят email обычных пользователей
|
||||
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
|
||||
email = None
|
||||
else:
|
||||
email = row["email"]
|
||||
|
||||
# Получаем активную проходку
|
||||
pass_info = None
|
||||
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
|
||||
pass_row = 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 = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
LIMIT 1
|
||||
""", (user_id, time.time())).fetchone()
|
||||
|
||||
if pass_row:
|
||||
pass_info = {
|
||||
"code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"],
|
||||
"expires_at": pass_row["expires_at"],
|
||||
"activated_at": pass_row["activated_at"]
|
||||
}
|
||||
|
||||
# Логи действий (только для Elder+)
|
||||
actions = []
|
||||
if current_user["role"] >= ROLE_ELDER:
|
||||
action_rows = conn.execute("""
|
||||
SELECT action, details, timestamp FROM audit_log
|
||||
WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20
|
||||
""", (user_id,)).fetchall()
|
||||
actions = [dict(row) for row in action_rows]
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"email": email,
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
"is_active": row["is_active"],
|
||||
"banned_until": row["banned_until"],
|
||||
"has_pass": pass_info is not None,
|
||||
"pass_info": pass_info,
|
||||
"recent_actions": actions if current_user["role"] >= ROLE_ELDER else None
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/role")
|
||||
async def update_user_role(
|
||||
user_id: int,
|
||||
body: UpdateRoleRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Изменение роли пользователя"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Проверки прав
|
||||
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Нельзя изменить роль создателя")
|
||||
|
||||
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем")
|
||||
|
||||
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})")
|
||||
|
||||
# Elder не может создавать других Elder (только Creator)
|
||||
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Только создатель может назначать Elder Moderator")
|
||||
|
||||
# Проверяем, нужно ли выдать/отозвать проходку
|
||||
old_role = target["role"]
|
||||
new_role = body.role
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET role = ? WHERE id = ?",
|
||||
(new_role, user_id)
|
||||
)
|
||||
|
||||
# Управление проходками при изменении роли
|
||||
now = time.time()
|
||||
|
||||
if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER:
|
||||
# Выдаем проходку если её нет
|
||||
existing = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (user_id, now)).fetchone()
|
||||
|
||||
if not existing:
|
||||
# Создаем автоматическую проходку
|
||||
pass_code = f"AUTO_{secrets.token_hex(8).upper()}"
|
||||
conn.execute("""
|
||||
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
|
||||
VALUES (?, ?, NULL, 1, 1)
|
||||
""", (pass_code, target["username"]))
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (user_id, pass_code, now))
|
||||
|
||||
logger.info("Auto-pass issued", user=target["username"], role=new_role)
|
||||
|
||||
elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER:
|
||||
# Отзываем проходку
|
||||
conn.execute("""
|
||||
UPDATE passes SET is_active = 0
|
||||
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||
""", (user_id,))
|
||||
|
||||
logger.info("Auto-pass revoked", user=target["username"])
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"role_change",
|
||||
f"Changed role of {target['username']} from {old_role} to {new_role}",
|
||||
ip
|
||||
)
|
||||
|
||||
logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"user_id": user_id,
|
||||
"username": target["username"],
|
||||
"old_role": old_role,
|
||||
"old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"),
|
||||
"new_role": new_role,
|
||||
"new_role_name": ROLE_NAMES.get(new_role, "Неизвестно")
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pass/grant")
|
||||
async def grant_pass(
|
||||
body: CreatePassDirectRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Выдача проходки пользователю (Elder+ могут выдавать)"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Проверяем право на прямую выдачу
|
||||
if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS):
|
||||
raise HTTPException(403, "Недостаточно прав для выдачи проходки")
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(body.username,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, f"Пользователь {body.username} не найден")
|
||||
|
||||
# Проверяем, есть ли уже активная проходка
|
||||
existing = conn.execute("""
|
||||
SELECT p.code FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (target["id"], time.time())).fetchone()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка")
|
||||
|
||||
# Создаем проходку
|
||||
pass_code = secrets.token_hex(12).upper()
|
||||
now = time.time()
|
||||
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
|
||||
VALUES (?, ?, ?, ?, 1)
|
||||
""", (pass_code, target["username"], expires_at, body.max_uses))
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||
VALUES (?, ?, ?)
|
||||
""", (target["id"], pass_code, now))
|
||||
|
||||
# Обновляем роль если нужно
|
||||
if target["role"] < ROLE_PASS_HOLDER:
|
||||
conn.execute(
|
||||
"UPDATE users SET role = ? WHERE id = ?",
|
||||
(ROLE_PASS_HOLDER, target["id"])
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"grant_pass",
|
||||
f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})",
|
||||
ip
|
||||
)
|
||||
|
||||
logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"pass_code": pass_code,
|
||||
"username": target["username"],
|
||||
"expires_at": expires_at,
|
||||
"expires_days": body.expires_days,
|
||||
"max_uses": body.max_uses
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/pass/revoke/{username}")
|
||||
async def revoke_pass(
|
||||
username: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Отзыв проходки у пользователя"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
|
||||
(username,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, f"Пользователь {username} не найден")
|
||||
|
||||
# Отзываем проходку
|
||||
conn.execute("""
|
||||
UPDATE passes SET is_active = 0
|
||||
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||
""", (target["id"],))
|
||||
|
||||
# Понижаем роль если она была только из-за проходки
|
||||
if target["role"] == ROLE_PASS_HOLDER:
|
||||
conn.execute(
|
||||
"UPDATE users SET role = ? WHERE id = ?",
|
||||
(ROLE_USER, target["id"])
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip)
|
||||
logger.info("Pass revoked", admin=current_user["username"], target=username)
|
||||
|
||||
return {"success": True, "message": f"Проходка {username} отозвана"}
|
||||
|
||||
|
||||
@router.post("/user/ban")
|
||||
async def ban_user(
|
||||
body: BanUserRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Бан пользователя (Elder+ могут банить)"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE id = ?",
|
||||
(body.user_id,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
# Нельзя забанить создателя
|
||||
if target["role"] == ROLE_CREATOR:
|
||||
raise HTTPException(403, "Нельзя забанить создателя")
|
||||
|
||||
# Elder не может банить других Elder
|
||||
if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Недостаточно прав для бана этого пользователя")
|
||||
|
||||
banned_until = time.time() + (body.days * 86400)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?",
|
||||
(banned_until, target["id"])
|
||||
)
|
||||
|
||||
# Отзываем проходку при бане
|
||||
conn.execute("""
|
||||
UPDATE passes SET is_active = 0
|
||||
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||
""", (target["id"],))
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(
|
||||
current_user["id"],
|
||||
"ban_user",
|
||||
f"Banned {target['username']} for {body.days} days. Reason: {body.reason}",
|
||||
ip
|
||||
)
|
||||
|
||||
logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"username": target["username"],
|
||||
"banned_until": banned_until,
|
||||
"days": body.days
|
||||
}
|
||||
|
||||
|
||||
@router.post("/user/unban/{user_id}")
|
||||
async def unban_user(
|
||||
user_id: int,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None
|
||||
):
|
||||
"""Разбан пользователя"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?",
|
||||
(user_id,)
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
raise HTTPException(404, "Пользователь не найден")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip)
|
||||
logger.info("User unbanned", admin=current_user["username"], target=target["username"])
|
||||
|
||||
return {"success": True, "username": target["username"]}
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def get_audit_log(
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
user_id: Optional[int] = None
|
||||
):
|
||||
"""Просмотр логов аудита (только Elder+)"""
|
||||
with get_db() as conn:
|
||||
query = """
|
||||
SELECT al.*, u.username
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
"""
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
query += " WHERE al.user_id = ?"
|
||||
params.append(user_id)
|
||||
|
||||
query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"]
|
||||
|
||||
return {
|
||||
"logs": [dict(row) for row in rows],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR))
|
||||
):
|
||||
"""Статистика для админов"""
|
||||
with get_db() as conn:
|
||||
# Общая статистика
|
||||
total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"]
|
||||
|
||||
# Статистика по ролям
|
||||
role_stats = conn.execute("""
|
||||
SELECT role, COUNT(*) as count
|
||||
FROM users
|
||||
GROUP BY role
|
||||
ORDER BY role DESC
|
||||
""").fetchall()
|
||||
|
||||
# Активные проходки
|
||||
active_passes = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE p.expires_at IS NULL OR p.expires_at > ?
|
||||
""", (time.time(),)).fetchone()["count"]
|
||||
|
||||
# Забаненные пользователи
|
||||
banned_users = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM users
|
||||
WHERE is_active = 0 AND banned_until > ?
|
||||
""", (time.time(),)).fetchone()["count"]
|
||||
|
||||
# Недавние регистрации (последние 7 дней)
|
||||
week_ago = time.time() - (7 * 86400)
|
||||
recent_registrations = conn.execute("""
|
||||
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
||||
""", (week_ago,)).fetchone()["count"]
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_passes": active_passes,
|
||||
"banned_users": banned_users,
|
||||
"recent_registrations_7d": recent_registrations,
|
||||
"roles_distribution": [
|
||||
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
|
||||
for r in role_stats
|
||||
],
|
||||
"my_info": {
|
||||
"role": current_user["role"],
|
||||
"role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"),
|
||||
"username": current_user["username"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_info(current_user: dict = Depends(get_current_user)):
|
||||
"""Информация о текущем пользователе с правами"""
|
||||
with get_db() as conn:
|
||||
row = conn.execute("""
|
||||
SELECT id, username, uuid, role, created_at, last_login
|
||||
FROM users WHERE id = ?
|
||||
""", (current_user["id"],)).fetchone()
|
||||
|
||||
# Проверяем наличие активной проходки
|
||||
has_pass = False
|
||||
if row["role"] >= ROLE_PASS_HOLDER:
|
||||
pass_row = conn.execute("""
|
||||
SELECT 1 FROM user_passes up
|
||||
JOIN passes p ON up.pass_code = p.code
|
||||
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||
""", (current_user["id"], time.time())).fetchone()
|
||||
has_pass = pass_row is not None
|
||||
|
||||
permissions = list(ROLE_PERMISSIONS.get(row["role"], set()))
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
"has_pass": has_pass,
|
||||
"permissions": permissions
|
||||
}
|
||||
+605
-260
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
# http_logger.py
|
||||
import logging
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import time
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
class HTTPLogger:
|
||||
"""Custom HTTP logger to catch invalid requests"""
|
||||
|
||||
@staticmethod
|
||||
def log_invalid_request(data: bytes, client_addr: tuple):
|
||||
"""Log invalid HTTP requests"""
|
||||
try:
|
||||
# Try to decode as much as possible
|
||||
request_str = data.decode('utf-8', errors='replace')[:500]
|
||||
logger.warning(
|
||||
f"Invalid HTTP request received\n"
|
||||
f"Client: {client_addr[0]}:{client_addr[1]}\n"
|
||||
f"Data: {request_str}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid HTTP request from {client_addr}, could not decode: {e}")
|
||||
+448
-46
@@ -1,12 +1,15 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
@@ -15,11 +18,9 @@ from middleware import LoggingMiddleware
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||
from log_manager import init_logging
|
||||
|
||||
import httpx
|
||||
import base64
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from auth import router as auth_router, init_db, verify_jwt
|
||||
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||
from roles import Permissions, has_permission
|
||||
from admin_router import router as admin_router
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -35,7 +36,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Initialize logging
|
||||
init_logging()
|
||||
#logger = logging.getLogger(__name__)
|
||||
|
||||
# Determine environment
|
||||
if args.test:
|
||||
@@ -53,8 +53,6 @@ async def lifespan(app: FastAPI):
|
||||
DATA_DIR.mkdir(exist_ok=True)
|
||||
|
||||
init_db()
|
||||
|
||||
app.include_router(auth_router)
|
||||
|
||||
if args.test:
|
||||
await run_test_mode()
|
||||
@@ -76,16 +74,355 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
|
||||
|
||||
logger.info("All packs ready. Server is running.")
|
||||
|
||||
# Initialize proxy client
|
||||
global proxy_client
|
||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup proxy client
|
||||
if proxy_client:
|
||||
await proxy_client.aclose()
|
||||
|
||||
logger.info("Server shutting down...")
|
||||
|
||||
|
||||
|
||||
# ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ======================
|
||||
ACTIVATE_PASS_HTML = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Активация проходки | ZernMC</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
color: #00d4ff;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 20px rgba(0, 212, 255, 0.5);
|
||||
}
|
||||
|
||||
.logo p {
|
||||
color: #8892b0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: #ccd6f6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #00d4ff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 4px rgba(0, 212, 255, 0.1);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #8892b0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #0099cc 100%);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
#message {
|
||||
margin-top: 20px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(0, 255, 100, 0.15);
|
||||
border: 1px solid rgba(0, 255, 100, 0.3);
|
||||
color: #00ff64;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(255, 50, 50, 0.15);
|
||||
border: 1px solid rgba(255, 50, 50, 0.3);
|
||||
color: #ff5050;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: white;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
color: #8892b0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #00d4ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<h1>ZernMC</h1>
|
||||
<p>Активация проходки</p>
|
||||
</div>
|
||||
|
||||
<form id="activateForm">
|
||||
<div class="form-group">
|
||||
<label for="username">Ваш никнейм</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="Введите ваш ник в игре"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="16"
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="passCode">Код проходки</label>
|
||||
<input
|
||||
type="text"
|
||||
id="passCode"
|
||||
name="passCode"
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
required
|
||||
minlength="8"
|
||||
maxlength="20"
|
||||
autocomplete="off"
|
||||
style="text-transform: uppercase;"
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submitBtn">
|
||||
Активировать проходку
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Нет проходки? <a href="https://zernmc.ru" target="_blank">Получить на сайте</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const form = document.getElementById('activateForm');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const messageDiv = document.getElementById('message');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passCodeInput = document.getElementById('passCode');
|
||||
|
||||
passCodeInput.addEventListener('input', (e) => {
|
||||
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
|
||||
});
|
||||
|
||||
function showMessage(text, type) {
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.className = '';
|
||||
messageDiv.classList.add(type);
|
||||
messageDiv.style.display = 'block';
|
||||
|
||||
if (type === 'success' || type === 'info') {
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = usernameInput.value.trim();
|
||||
const passCode = passCodeInput.value.trim().toUpperCase();
|
||||
const password = prompt("Введите пароль от вашего аккаунта " + username + ":");
|
||||
|
||||
if (!password) {
|
||||
showMessage('Необходимо ввести пароль', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username || !passCode) {
|
||||
showMessage('Пожалуйста, заполните все поля', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.innerHTML = 'Активация... <span class="spinner"></span>';
|
||||
messageDiv.style.display = 'none';
|
||||
|
||||
try {
|
||||
// Логинимся с существующим аккаунтом
|
||||
const loginResponse = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, password: password })
|
||||
});
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
const errorData = await loginResponse.json();
|
||||
throw new Error(errorData.detail || 'Неверный логин или пароль');
|
||||
}
|
||||
|
||||
const tokenData = await loginResponse.json();
|
||||
|
||||
// Активируем проходку
|
||||
const activateResponse = await fetch('/auth/pass/activate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${tokenData.access_token}`
|
||||
},
|
||||
body: JSON.stringify({ pass_code: passCode })
|
||||
});
|
||||
|
||||
if (!activateResponse.ok) {
|
||||
const errorData = await activateResponse.json();
|
||||
throw new Error(errorData.detail || 'Ошибка активации проходки');
|
||||
}
|
||||
|
||||
const activateData = await activateResponse.json();
|
||||
showMessage('✅ ' + activateData.message, 'success');
|
||||
form.reset();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showMessage('❌ ' + error.message, 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# Create app with lifespan
|
||||
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||
|
||||
# Add logging middleware
|
||||
# Add Logging middleware
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
# Monkey patch to catch invalid HTTP requests
|
||||
original_data_received = HttpToolsProtocol.data_received
|
||||
@@ -95,9 +432,7 @@ def patched_data_received(self, data):
|
||||
return original_data_received(self, data)
|
||||
except Exception as e:
|
||||
client = self.transport.get_extra_info('peername')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Показываем первые 200 байт запроса в HEX для диагностики
|
||||
hex_preview = data[:100].hex() if len(data) > 0 else "empty"
|
||||
|
||||
logger.error(f"Invalid HTTP request from {client}")
|
||||
@@ -107,16 +442,14 @@ def patched_data_received(self, data):
|
||||
try:
|
||||
raw_data = data[:500].decode('utf-8', errors='replace')
|
||||
logger.error(f"Raw request data: {repr(raw_data)}")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Не перевыбрасываем исключение, а возвращаем 400 ответ
|
||||
# Это важно! Иначе клиент не получит ответ
|
||||
try:
|
||||
response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request"
|
||||
self.transport.write(response)
|
||||
self.transport.close()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
@@ -128,7 +461,6 @@ HttpToolsProtocol.data_received = patched_data_received
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint"""
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Root endpoint accessed")
|
||||
return {
|
||||
"status": "ok",
|
||||
@@ -144,12 +476,30 @@ async def health():
|
||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
# ====================== WEB ИНТЕРФЕЙС ДЛЯ АКТИВАЦИИ ПРОХОДКИ ======================
|
||||
|
||||
@app.get("/activate-pass")
|
||||
async def activate_pass_page():
|
||||
"""Веб-интерфейс для активации проходки"""
|
||||
return Response(
|
||||
content=ACTIVATE_PASS_HTML,
|
||||
media_type="text/html"
|
||||
)
|
||||
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs():
|
||||
"""List all available packs"""
|
||||
logger = logging.getLogger(__name__)
|
||||
async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||
"""List all available packs - требует проходку для просмотра"""
|
||||
|
||||
# Проверяем, есть ли право на просмотр сборок
|
||||
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Для просмотра сборок требуется активная проходка"
|
||||
)
|
||||
|
||||
packs = []
|
||||
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
@@ -159,7 +509,6 @@ async def list_packs():
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
meta = json.load(f)
|
||||
# Исправлено: конвертируем updated_at в строку если это datetime
|
||||
updated_at = meta.get("updated_at")
|
||||
if updated_at and isinstance(updated_at, datetime):
|
||||
updated_at = updated_at.isoformat()
|
||||
@@ -171,7 +520,8 @@ async def list_packs():
|
||||
"updated_at": updated_at,
|
||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||
"loader_type": meta.get("loader_type", "vanilla"),
|
||||
"loader_version": meta.get("loader_version")
|
||||
"loader_version": meta.get("loader_version"),
|
||||
"asset_index": meta.get("asset_index")
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
|
||||
@@ -189,11 +539,22 @@ async def list_packs():
|
||||
|
||||
|
||||
@app.post("/pack/{pack_name}/diff")
|
||||
async def get_pack_diff(pack_name: str, request: Request):
|
||||
"""
|
||||
Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||
async def get_pack_diff(
|
||||
pack_name: str,
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user) # Добавляем зависимость
|
||||
):
|
||||
"""Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||
Server returns diff information
|
||||
"""
|
||||
ТРЕБУЕТ ПРОХОДКУ ДЛЯ СКАЧИВАНИЯ"""
|
||||
|
||||
# Проверяем наличие проходки
|
||||
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору."
|
||||
)
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Читаем тело запроса
|
||||
@@ -491,17 +852,12 @@ async def get_launcher_full_info():
|
||||
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
|
||||
# скачивать файлы через сервер Zern
|
||||
|
||||
# Создаем HTTP клиент для прокси
|
||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
# HTTP клиент для прокси — создаётся в lifespan, закрывается при shutdown
|
||||
proxy_client: httpx.AsyncClient | None = None
|
||||
|
||||
# Кэш для часто запрашиваемых данных (5 минут)
|
||||
from cachetools import TTLCache
|
||||
proxy_cache = TTLCache(maxsize=50, ttl=300)
|
||||
|
||||
# Список заблокированных/проблемных хостов (можно обновлять)
|
||||
BLOCKED_HOSTS = []
|
||||
|
||||
|
||||
@app.get("/proxy/fabric/versions/loader")
|
||||
async def proxy_fabric_versions(request: Request):
|
||||
"""Прокси для Fabric Meta API - список версий загрузчика"""
|
||||
@@ -548,7 +904,6 @@ async def proxy_fabric_installer_latest(request: Request):
|
||||
xml = response.text
|
||||
|
||||
# Парсим последнюю версию из XML
|
||||
import re
|
||||
match = re.search(r'<latest>([^<]+)</latest>', xml)
|
||||
if match:
|
||||
version = match.group(1)
|
||||
@@ -725,6 +1080,58 @@ async def proxy_forge_maven(path: str, request: Request):
|
||||
raise HTTPException(502, f"Bad Gateway: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/proxy/neoforge/versions")
|
||||
async def proxy_neoforge_versions(request: Request):
|
||||
"""Прокси для списка версий NeoForge"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
logger.info(f"Proxy request: NeoForge versions from {client_ip}")
|
||||
|
||||
url = "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml"
|
||||
|
||||
try:
|
||||
response = await proxy_client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
media_type="application/xml",
|
||||
headers={"X-Proxied-By": "ZernMC"}
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Proxy error for NeoForge versions: {e}")
|
||||
raise HTTPException(502, f"Bad Gateway: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/proxy/neoforge/maven/{path:path}")
|
||||
async def proxy_neoforge_maven(path: str, request: Request):
|
||||
"""Прокси для NeoForge Maven файлов"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
logger.info(f"Proxy request: NeoForge Maven {path} from {client_ip}")
|
||||
|
||||
full_url = f"https://maven.neoforged.net/{path}"
|
||||
|
||||
try:
|
||||
response = await proxy_client.get(full_url)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = "application/octet-stream"
|
||||
if path.endswith(".jar"):
|
||||
content_type = "application/java-archive"
|
||||
elif path.endswith(".pom"):
|
||||
content_type = "application/xml"
|
||||
|
||||
return Response(
|
||||
content=response.content,
|
||||
media_type=content_type,
|
||||
headers={"X-Proxied-By": "ZernMC"}
|
||||
)
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Proxy error for NeoForge Maven {path}: {e}")
|
||||
raise HTTPException(502, f"Bad Gateway: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/proxy/download")
|
||||
async def proxy_download(request: Request):
|
||||
"""Универсальный прокси для скачивания файлов"""
|
||||
@@ -742,11 +1149,11 @@ async def proxy_download(request: Request):
|
||||
"launchermeta.mojang.com",
|
||||
"resources.download.minecraft.net",
|
||||
"maven.minecraftforge.net",
|
||||
"files.minecraftforge.net"
|
||||
"files.minecraftforge.net",
|
||||
"maven.neoforged.net"
|
||||
]
|
||||
|
||||
# Проверяем, что URL ведет на разрешенный домен
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
|
||||
@@ -819,7 +1226,8 @@ async def proxy_status():
|
||||
"piston-meta.mojang.com",
|
||||
"launchermeta.mojang.com",
|
||||
"resources.download.minecraft.net",
|
||||
"maven.minecraftforge.net"
|
||||
"maven.minecraftforge.net",
|
||||
"maven.neoforged.net"
|
||||
],
|
||||
"note": "Use this proxy if you have network issues connecting to Fabric/Mojang/Forge"
|
||||
}
|
||||
@@ -834,12 +1242,6 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
)
|
||||
|
||||
|
||||
# Cleanup on shutdown
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_proxy():
|
||||
await proxy_client.close()
|
||||
|
||||
|
||||
# ====================== ЗАПУСК ======================
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+1
-13
@@ -28,16 +28,4 @@ class PackMeta(BaseModel):
|
||||
minecraft_version: str
|
||||
loader_type: str
|
||||
loader_version: Optional[str] = None
|
||||
|
||||
class MinecraftVersion(BaseModel):
|
||||
version: str
|
||||
type: str # release, snapshot, old_alpha, old_beta
|
||||
release_time: datetime
|
||||
url: Optional[str] = None
|
||||
|
||||
class ModLoader(BaseModel):
|
||||
type: str
|
||||
version: str
|
||||
minecraft_version: str
|
||||
installer_url: Optional[str] = None
|
||||
libraries: List[str] = Field(default_factory=list)
|
||||
asset_index: Optional[str] = None
|
||||
@@ -109,6 +109,7 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
minecraft_version = "1.20.4"
|
||||
loader_type = "vanilla"
|
||||
loader_version = None
|
||||
asset_index = None
|
||||
|
||||
pack_config_path = pack_path / "instance.json"
|
||||
if pack_config_path.exists():
|
||||
@@ -119,6 +120,7 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
minecraft_version = config.get("minecraftVersion", minecraft_version)
|
||||
loader_type = config.get("loaderType", loader_type)
|
||||
loader_version = config.get("loaderVersion")
|
||||
asset_index = config.get("assetIndex")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load instance.json for {pack_name}: {e}")
|
||||
|
||||
@@ -131,7 +133,8 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
||||
ignored_dirs=ignored_dirs,
|
||||
minecraft_version=minecraft_version,
|
||||
loader_type=loader_type,
|
||||
loader_version=loader_version
|
||||
loader_version=loader_version,
|
||||
asset_index=asset_index
|
||||
)
|
||||
|
||||
# Save to disk (синхронно)
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
# roles.py
|
||||
from enum import IntEnum
|
||||
from typing import Dict, Set
|
||||
|
||||
class UserRole(IntEnum):
|
||||
USER = 0 # Обычный пользователь
|
||||
PASS_HOLDER = 1 # Пользователь с проходкой
|
||||
MODERATOR = 2 # Модератор
|
||||
ELDER = 3 # Elder Moderator
|
||||
CREATOR = 4 # Создатель
|
||||
|
||||
# Aliases for backwards compatibility with admin_router.py
|
||||
ROLE_USER = UserRole.USER
|
||||
ROLE_PASS_HOLDER = UserRole.PASS_HOLDER
|
||||
ROLE_MODERATOR = UserRole.MODERATOR
|
||||
ROLE_ELDER = UserRole.ELDER
|
||||
ROLE_CREATOR = UserRole.CREATOR
|
||||
|
||||
__all__ = [
|
||||
"UserRole", "ROLE_USER", "ROLE_PASS_HOLDER", "ROLE_MODERATOR",
|
||||
"ROLE_ELDER", "ROLE_CREATOR", "ROLE_NAMES", "Permissions",
|
||||
"ROLE_PERMISSIONS", "has_permission", "require_permission",
|
||||
]
|
||||
|
||||
ROLE_NAMES: Dict[int, str] = {
|
||||
UserRole.USER: "Игрок",
|
||||
UserRole.PASS_HOLDER: "Игрок [Проходка]",
|
||||
UserRole.MODERATOR: "Модератор",
|
||||
UserRole.ELDER: "Elder Moderator",
|
||||
UserRole.CREATOR: "Создатель"
|
||||
}
|
||||
|
||||
# Права доступа
|
||||
class Permissions:
|
||||
# Базовые права
|
||||
DOWNLOAD_PACK = "download_pack" # Скачивание сборок
|
||||
VIEW_PACKS = "view_packs" # Просмотр списка сборок
|
||||
|
||||
# Права модератора
|
||||
REQUEST_PASS = "request_pass" # Запрос проходки для игрока
|
||||
VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей
|
||||
|
||||
# Права Elder Moderator
|
||||
APPROVE_PASS = "approve_pass" # Одобрение проходок
|
||||
REJECT_PASS = "reject_pass" # Отклонение проходок
|
||||
VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок
|
||||
MANAGE_MODERATORS = "manage_moderators" # Управление модераторами
|
||||
|
||||
# Права создателя
|
||||
DIRECT_PASS = "direct_pass" # Прямая выдача проходки
|
||||
MANAGE_ELDER = "manage_elder" # Управление Elder
|
||||
MANAGE_SERVER = "manage_server" # Управление сервером
|
||||
VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов
|
||||
|
||||
# Маппинг ролей на права
|
||||
ROLE_PERMISSIONS: Dict[int, Set[str]] = {
|
||||
UserRole.USER: {
|
||||
# Обычный игрок НЕ может даже смотреть сборки!
|
||||
# Только авторизоваться и смотреть свой профиль
|
||||
},
|
||||
UserRole.PASS_HOLDER: {
|
||||
Permissions.VIEW_PACKS, # Может видеть список сборок
|
||||
Permissions.DOWNLOAD_PACK, # Может скачивать сборки
|
||||
},
|
||||
UserRole.MODERATOR: {
|
||||
Permissions.VIEW_PACKS,
|
||||
Permissions.DOWNLOAD_PACK,
|
||||
Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков
|
||||
Permissions.VIEW_USER_LIST, # Может видеть список пользователей
|
||||
},
|
||||
UserRole.ELDER: {
|
||||
Permissions.VIEW_PACKS,
|
||||
Permissions.DOWNLOAD_PACK,
|
||||
Permissions.REQUEST_PASS,
|
||||
Permissions.VIEW_USER_LIST,
|
||||
Permissions.APPROVE_PASS, # Может одобрять проходки
|
||||
Permissions.REJECT_PASS, # Может отклонять проходки
|
||||
Permissions.VIEW_PASS_REQUESTS,
|
||||
Permissions.MANAGE_MODERATORS, # Может управлять модераторами
|
||||
},
|
||||
UserRole.CREATOR: {
|
||||
Permissions.VIEW_PACKS,
|
||||
Permissions.DOWNLOAD_PACK,
|
||||
Permissions.REQUEST_PASS,
|
||||
Permissions.VIEW_USER_LIST,
|
||||
Permissions.APPROVE_PASS,
|
||||
Permissions.REJECT_PASS,
|
||||
Permissions.VIEW_PASS_REQUESTS,
|
||||
Permissions.MANAGE_MODERATORS,
|
||||
Permissions.DIRECT_PASS, # Прямая выдача проходки
|
||||
Permissions.MANAGE_ELDER, # Управление Elder
|
||||
Permissions.MANAGE_SERVER, # Управление сервером
|
||||
Permissions.VIEW_AUDIT_LOG, # Просмотр логов
|
||||
}
|
||||
}
|
||||
|
||||
def has_permission(role: int, permission: str) -> bool:
|
||||
"""Проверка наличия права у роли"""
|
||||
return permission in ROLE_PERMISSIONS.get(role, set())
|
||||
|
||||
def require_permission(permission: str):
|
||||
"""Декоратор для проверки права"""
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException, Depends
|
||||
from auth import get_current_user
|
||||
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs):
|
||||
if not has_permission(current_user["role"], permission):
|
||||
raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}")
|
||||
return await func(*args, current_user=current_user, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,125 @@
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def auth_headers(token):
|
||||
"""Create Authorization headers."""
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_db_dir():
|
||||
"""Temporary directory for test databases."""
|
||||
d = tempfile.mkdtemp(prefix="zern_test_")
|
||||
yield Path(d)
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_app(test_db_dir):
|
||||
"""Create FastAPI app with test database."""
|
||||
# Patch auth module paths BEFORE importing anything
|
||||
import auth
|
||||
auth.AUTH_DB = test_db_dir / "auth.db"
|
||||
auth.SECRET_KEY = test_db_dir / ".secret_key"
|
||||
auth._rate_limit_cache.clear()
|
||||
|
||||
# Initialize test database
|
||||
auth.init_db()
|
||||
|
||||
from main import app
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(test_app):
|
||||
"""TestClient instance."""
|
||||
from fastapi.testclient import TestClient
|
||||
return TestClient(test_app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registered_user(client):
|
||||
"""Register a unique test user."""
|
||||
import secrets
|
||||
username = f"testuser_{secrets.token_hex(4)}"
|
||||
password = "TestPassword123"
|
||||
|
||||
resp = client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, f"Registration failed: {resp.text}"
|
||||
return {"username": username, "password": password}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_user(client, registered_user):
|
||||
"""Login and return tokens."""
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||
data = resp.json()
|
||||
return {
|
||||
"username": registered_user["username"],
|
||||
"password": registered_user["password"],
|
||||
"access_token": data["access_token"],
|
||||
"refresh_token": data["refresh_token"],
|
||||
"expires_in": data["expires_in"],
|
||||
"uuid": data["uuid"],
|
||||
"role": data["role"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def logged_in_user_with_pass(client, registered_user):
|
||||
"""Login user and give them role 1 (pass holder)."""
|
||||
# Promote to pass holder
|
||||
import sqlite3
|
||||
import auth
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("UPDATE users SET role = 1 WHERE username = ?", (registered_user["username"],))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||
data = resp.json()
|
||||
return {
|
||||
"username": registered_user["username"],
|
||||
"password": registered_user["password"],
|
||||
"access_token": data["access_token"],
|
||||
"refresh_token": data["refresh_token"],
|
||||
"expires_in": data["expires_in"],
|
||||
"uuid": data["uuid"],
|
||||
"role": data["role"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(client):
|
||||
"""Create and login a creator/admin user."""
|
||||
import secrets
|
||||
import sqlite3
|
||||
import auth
|
||||
|
||||
username = f"admin_{secrets.token_hex(4)}"
|
||||
password = "AdminPassword123"
|
||||
|
||||
resp = client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Promote to creator
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute("UPDATE users SET role = 4 WHERE username = ?", (username,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
return {
|
||||
"username": username,
|
||||
"access_token": data["access_token"],
|
||||
"refresh_token": data["refresh_token"],
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Tests for admin endpoints."""
|
||||
import pytest
|
||||
import sqlite3
|
||||
import time
|
||||
from tests.conftest import auth_headers
|
||||
from auth import AUTH_DB
|
||||
|
||||
|
||||
class TestAdminMe:
|
||||
"""Test /admin/me endpoint."""
|
||||
|
||||
def test_admin_me_success(self, client, logged_in_user):
|
||||
resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert "username" in data
|
||||
assert "uuid" in data
|
||||
assert "role" in data
|
||||
assert "role_name" in data
|
||||
assert "has_pass" in data
|
||||
assert "permissions" in data
|
||||
|
||||
def test_admin_me_no_auth(self, client):
|
||||
resp = client.get("/admin/me")
|
||||
assert resp.status_code in (401, 403) # Either is acceptable
|
||||
|
||||
|
||||
class TestAdminUsersList:
|
||||
"""Test /admin/users endpoint."""
|
||||
|
||||
def test_admin_users_list(self, client, admin_user):
|
||||
resp = client.get("/admin/users", headers=auth_headers(admin_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "users" in data
|
||||
assert isinstance(data["users"], list)
|
||||
assert len(data["users"]) >= 1 # At least the admin user
|
||||
|
||||
def test_admin_users_list_no_admin(self, client, logged_in_user):
|
||||
"""Regular user should not access admin endpoints."""
|
||||
resp = client.get("/admin/users", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_admin_users_list_no_auth(self, client):
|
||||
resp = client.get("/admin/users")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
class TestAdminBan:
|
||||
"""Test ban functionality via admin endpoints."""
|
||||
|
||||
def test_ban_user(self, client, logged_in_user, admin_user):
|
||||
"""Admin bans a user."""
|
||||
# Get user ID first
|
||||
import sqlite3
|
||||
from auth import AUTH_DB
|
||||
conn = sqlite3.connect(str(AUTH_DB))
|
||||
row = conn.execute("SELECT id FROM users WHERE username = ?",
|
||||
(logged_in_user["username"],)).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
|
||||
resp = client.post("/admin/user/ban", json={
|
||||
"user_id": row[0],
|
||||
"days": 1,
|
||||
"reason": "Test ban"
|
||||
}, headers=auth_headers(admin_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify ban in DB
|
||||
conn = sqlite3.connect(str(AUTH_DB))
|
||||
row = conn.execute("SELECT banned_until FROM users WHERE username = ?",
|
||||
(logged_in_user["username"],)).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row[0] is not None
|
||||
assert row[0] > time.time()
|
||||
|
||||
def test_ban_nonexistent_user(self, client, admin_user):
|
||||
resp = client.post("/admin/user/ban", json={
|
||||
"user_id": 99999,
|
||||
"days": 1,
|
||||
"reason": "Test ban"
|
||||
}, headers=auth_headers(admin_user["access_token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestAdminRole:
|
||||
"""Test role change functionality."""
|
||||
|
||||
def test_change_role(self, client, logged_in_user, admin_user):
|
||||
# Get user ID
|
||||
import sqlite3
|
||||
from auth import AUTH_DB
|
||||
conn = sqlite3.connect(str(AUTH_DB))
|
||||
row = conn.execute("SELECT id FROM users WHERE username = ?",
|
||||
(logged_in_user["username"],)).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
|
||||
resp = client.put(f"/admin/users/{row[0]}/role", json={
|
||||
"user_id": row[0],
|
||||
"role": 2 # MODERATOR
|
||||
}, headers=auth_headers(admin_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify in DB
|
||||
conn = sqlite3.connect(str(AUTH_DB))
|
||||
row = conn.execute("SELECT role FROM users WHERE username = ?",
|
||||
(logged_in_user["username"],)).fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 2
|
||||
|
||||
def test_change_role_invalid(self, client, logged_in_user, admin_user):
|
||||
import sqlite3
|
||||
from auth import AUTH_DB
|
||||
conn = sqlite3.connect(str(AUTH_DB))
|
||||
row = conn.execute("SELECT id FROM users WHERE username = ?",
|
||||
(logged_in_user["username"],)).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
|
||||
resp = client.put(f"/admin/users/{row[0]}/role", json={
|
||||
"user_id": row[0],
|
||||
"role": 99
|
||||
}, headers=auth_headers(admin_user["access_token"]))
|
||||
assert resp.status_code in (400, 422)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for auth flow: register, login, refresh, validate, logout."""
|
||||
import pytest
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestRegister:
|
||||
"""Test /auth/register endpoint."""
|
||||
|
||||
def test_register_success(self, client):
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": "newuser",
|
||||
"password": "SecurePass123"
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert "uuid" in data
|
||||
assert "expires_in" in data
|
||||
assert "role" in data
|
||||
assert data["username"] == "newuser"
|
||||
|
||||
def test_register_duplicate(self, client, registered_user):
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": registered_user["username"],
|
||||
"password": "AnotherPass123"
|
||||
})
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_register_short_username(self, client):
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": "ab",
|
||||
"password": "SecurePass123"
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_register_short_password(self, client):
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": "validuser",
|
||||
"password": "short"
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_register_invalid_username(self, client):
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": "user name!",
|
||||
"password": "SecurePass123"
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestLogin:
|
||||
"""Test /auth/login endpoint."""
|
||||
|
||||
def test_login_success(self, client, registered_user):
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert "uuid" in data
|
||||
assert data["username"] == registered_user["username"]
|
||||
|
||||
def test_login_wrong_password(self, client, registered_user):
|
||||
resp = client.post("/auth/login", json={
|
||||
"username": registered_user["username"],
|
||||
"password": "WrongPassword"
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_nonexistent_user(self, client):
|
||||
resp = client.post("/auth/login", json={
|
||||
"username": "ghost",
|
||||
"password": "SomePass123"
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_returns_role(self, client, registered_user):
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "role" in data
|
||||
assert data["role"] == 0 # ROLE_USER
|
||||
|
||||
|
||||
class TestRefresh:
|
||||
"""Test /auth/refresh endpoint."""
|
||||
|
||||
def test_refresh_success(self, client, logged_in_user):
|
||||
resp = client.post("/auth/refresh", json={
|
||||
"refresh_token": logged_in_user["refresh_token"]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["username"] == logged_in_user["username"]
|
||||
|
||||
def test_refresh_invalid_token(self, client):
|
||||
resp = client.post("/auth/refresh", json={
|
||||
"refresh_token": "invalid.token.here"
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_refresh_reuses_token_fails(self, client, logged_in_user):
|
||||
"""Refresh token should be invalidated after use."""
|
||||
# First refresh
|
||||
resp = client.post("/auth/refresh", json={
|
||||
"refresh_token": logged_in_user["refresh_token"]
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
new_token = resp.json()["refresh_token"]
|
||||
|
||||
# Try with old token
|
||||
resp = client.post("/auth/refresh", json={
|
||||
"refresh_token": logged_in_user["refresh_token"]
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""Test /auth/validate endpoint."""
|
||||
|
||||
def test_validate_valid_token(self, client, logged_in_user):
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": logged_in_user["username"],
|
||||
"uuid": logged_in_user["uuid"]
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["valid"] is True
|
||||
assert data["username"] == logged_in_user["username"]
|
||||
assert "uuid" in data
|
||||
|
||||
def test_validate_invalid_token(self, client):
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": "test",
|
||||
"uuid": "test"
|
||||
}, headers=auth_headers("invalid.token.here"))
|
||||
assert resp.status_code == 401 # Invalid token returns 401
|
||||
|
||||
def test_validate_no_token(self, client):
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": "test",
|
||||
"uuid": "test"
|
||||
})
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_validate_banned_user(self, client, logged_in_user, admin_user):
|
||||
"""Banned user should get valid=false."""
|
||||
# Ban the user
|
||||
import sqlite3
|
||||
from auth import AUTH_DB
|
||||
conn = sqlite3.connect(str(AUTH_DB))
|
||||
import time
|
||||
conn.execute("UPDATE users SET banned_until = ? WHERE username = ?",
|
||||
(time.time() + 3600, logged_in_user["username"]))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": logged_in_user["username"],
|
||||
"uuid": logged_in_user["uuid"]
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["valid"] is False
|
||||
assert "banned" in data["reason"].lower()
|
||||
|
||||
|
||||
class TestLogout:
|
||||
"""Test /auth/logout endpoint."""
|
||||
|
||||
def test_logout_success(self, client, logged_in_user):
|
||||
resp = client.post("/auth/logout", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Refresh should fail after logout
|
||||
resp = client.post("/auth/refresh", json={
|
||||
"refresh_token": logged_in_user["refresh_token"]
|
||||
})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_logout_invalid_token(self, client):
|
||||
resp = client.post("/auth/logout", headers=auth_headers("invalid.token.here"))
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code == 401
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Tests for client-facing endpoints — verifying server responses match what the Java launcher expects.
|
||||
|
||||
This tests the full client-server contract:
|
||||
- AuthManager.java: login, register, refresh, logout, /admin/me for UserInfo
|
||||
- PackDownloader.java: /packs, /pack/{name}, /pack/{name}/diff, /pack/{name}/file/{path}
|
||||
- ZHttpClient.java: /launcher/version, /proxy/*
|
||||
- ServerPack.java: pack list fields
|
||||
- PackManifest inner class: manifest fields
|
||||
- DiffResponse inner class: diff fields
|
||||
- FileInfo inner class: file info fields
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import auth_headers
|
||||
import auth
|
||||
from pack_manager import scan_pack, PACKS_DIR
|
||||
|
||||
|
||||
def scan_pack_sync(pack_name):
|
||||
"""Run scan_pack synchronously."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(scan_pack(pack_name))
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pack_fixture(tmp_path, logged_in_user):
|
||||
"""Create a test pack with a mod file and scan it."""
|
||||
pack_name = f"testpack_{secrets.token_hex(4)}"
|
||||
pack_dir = PACKS_DIR / pack_name
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mod_dir = pack_dir / "mods"
|
||||
mod_dir.mkdir()
|
||||
mod_content = b"fake mod content for testing"
|
||||
mod_file = mod_dir / "test-mod.jar"
|
||||
mod_file.write_bytes(mod_content)
|
||||
|
||||
# Scan to generate .meta
|
||||
meta = scan_pack_sync(pack_name)
|
||||
|
||||
yield {
|
||||
"name": pack_name,
|
||||
"dir": pack_dir,
|
||||
"mod_content": mod_content,
|
||||
"mod_path": "mods/test-mod.jar",
|
||||
"mod_hash": hashlib.sha256(mod_content).hexdigest(),
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
# Cleanup
|
||||
import shutil
|
||||
shutil.rmtree(pack_dir, ignore_errors=True)
|
||||
meta_path = Path("data") / f"{pack_name}.meta"
|
||||
if meta_path.exists():
|
||||
meta_path.unlink()
|
||||
|
||||
|
||||
class TestAuthFlowClient:
|
||||
"""Test auth flow exactly as Java AuthManager.java does it."""
|
||||
|
||||
def test_full_auth_lifecycle(self, client):
|
||||
"""Register → Login → Refresh → Logout, matching Java client behavior."""
|
||||
username = f"lifecycle_{secrets.token_hex(4)}"
|
||||
password = "LifeCyclePass123"
|
||||
|
||||
# 1. Register (AuthManager.authRequest)
|
||||
resp = client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200
|
||||
reg = resp.json()
|
||||
assert reg["access_token"]
|
||||
assert reg["refresh_token"]
|
||||
assert isinstance(reg["expires_in"], int)
|
||||
assert reg["uuid"]
|
||||
assert reg["username"] == username
|
||||
assert isinstance(reg["role"], int)
|
||||
|
||||
# 2. Login (AuthManager.authRequest)
|
||||
resp = client.post("/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200
|
||||
login = resp.json()
|
||||
assert login["access_token"]
|
||||
assert login["refresh_token"]
|
||||
assert isinstance(login["expires_in"], int)
|
||||
assert login["uuid"]
|
||||
assert login["username"] == username
|
||||
assert isinstance(login["role"], int)
|
||||
|
||||
access_token = login["access_token"]
|
||||
refresh_token = login["refresh_token"]
|
||||
|
||||
# 3. Refresh (AuthManager.tryRefresh)
|
||||
resp = client.post("/auth/refresh", json={"refresh_token": refresh_token})
|
||||
assert resp.status_code == 200
|
||||
refresh = resp.json()
|
||||
assert refresh["access_token"]
|
||||
assert refresh["refresh_token"]
|
||||
assert isinstance(refresh["expires_in"], int)
|
||||
assert refresh["username"] == username
|
||||
assert refresh["uuid"]
|
||||
assert isinstance(refresh["role"], int)
|
||||
|
||||
# 4. Validate token (used by Minecraft server auth)
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": username,
|
||||
"uuid": refresh["uuid"]
|
||||
}, headers=auth_headers(refresh["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
validate = resp.json()
|
||||
assert validate["valid"] is True
|
||||
assert validate["username"] == username
|
||||
|
||||
# 5. /admin/me (AuthManager.fetchUserInfo)
|
||||
resp = client.get("/admin/me", headers=auth_headers(refresh["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
me = resp.json()
|
||||
assert isinstance(me["id"], int)
|
||||
assert me["username"] == username
|
||||
assert me["uuid"]
|
||||
assert isinstance(me["role"], int)
|
||||
assert isinstance(me["role_name"], str)
|
||||
assert isinstance(me["has_pass"], bool)
|
||||
assert isinstance(me["permissions"], list)
|
||||
|
||||
# 6. Logout
|
||||
resp = client.post("/auth/logout", headers=auth_headers(refresh["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 7. Refresh should fail after logout
|
||||
resp = client.post("/auth/refresh", json={"refresh_token": refresh["refresh_token"]})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestPacksClientContract:
|
||||
"""Test /packs response matches Java ServerPack.java parsing."""
|
||||
|
||||
def test_packs_empty_list(self, client, logged_in_user_with_pass):
|
||||
"""Client parses {"packs": [...]} — empty list should work."""
|
||||
resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "packs" in data
|
||||
assert isinstance(data["packs"], list)
|
||||
|
||||
def test_packs_with_pack(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""Full pack with all fields that ServerPack.java expects."""
|
||||
resp = client.get("/packs", headers=auth_headers(logged_in_user_with_pass["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["packs"]) >= 1
|
||||
|
||||
# Find our pack
|
||||
pack = next((p for p in data["packs"] if p["name"] == pack_fixture["name"]), None)
|
||||
assert pack is not None
|
||||
|
||||
# ServerPack.java fields
|
||||
assert "name" in pack
|
||||
assert "version" in pack
|
||||
assert isinstance(pack["version"], int)
|
||||
assert "minecraft_version" in pack
|
||||
assert isinstance(pack["minecraft_version"], str)
|
||||
assert "loader_type" in pack
|
||||
assert isinstance(pack["loader_type"], str)
|
||||
assert "loader_version" in pack
|
||||
assert pack["loader_version"] is None or isinstance(pack["loader_version"], str)
|
||||
assert "files_count" in pack
|
||||
assert isinstance(pack["files_count"], int)
|
||||
assert "updated_at" in pack
|
||||
|
||||
|
||||
class TestPackManifestClientContract:
|
||||
"""Test /pack/{name} response matches Java PackDownloader.PackManifest."""
|
||||
|
||||
def test_pack_manifest_not_found(self, client, logged_in_user):
|
||||
resp = client.get("/pack/nonexistent", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_pack_manifest_fields(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""All fields that PackManifest.java expects."""
|
||||
pack_name = pack_fixture["name"]
|
||||
|
||||
resp = client.get(f"/pack/{pack_name}", headers=auth_headers(logged_in_user_with_pass["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# PackManifest.java fields
|
||||
assert "pack_name" in data
|
||||
assert "version" in data
|
||||
assert isinstance(data["version"], int)
|
||||
assert "minecraft_version" in data
|
||||
assert isinstance(data["minecraft_version"], str)
|
||||
assert "loader_type" in data
|
||||
assert "loader_version" in data or data.get("loader_version") is None
|
||||
assert "asset_index" in data or data.get("asset_index") is None
|
||||
assert "files" in data
|
||||
assert isinstance(data["files"], dict)
|
||||
|
||||
# Files in manifest have path, hash, size, added_at, modified_at
|
||||
# URL is only added in the diff response
|
||||
for path, entry in data["files"].items():
|
||||
assert "hash" in entry
|
||||
assert isinstance(entry["hash"], str)
|
||||
assert "size" in entry
|
||||
assert isinstance(entry["size"], int)
|
||||
|
||||
def test_pack_manifest_no_auth_is_public(self, client, pack_fixture):
|
||||
"""/pack/{name} is public — doesn't require auth."""
|
||||
resp = client.get(f"/pack/{pack_fixture['name']}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestPackDiffClientContract:
|
||||
"""Test /pack/{name}/diff response matches Java PackDownloader.DiffResponse."""
|
||||
|
||||
def test_diff_all_files_new(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""Client sends empty file list — all files should be in to_download."""
|
||||
pack_name = pack_fixture["name"]
|
||||
|
||||
resp = client.post(
|
||||
f"/pack/{pack_name}/diff",
|
||||
json={},
|
||||
headers=auth_headers(logged_in_user_with_pass["access_token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# DiffResponse.java fields
|
||||
assert "version" in data
|
||||
assert isinstance(data["version"], int)
|
||||
assert "to_download" in data
|
||||
assert isinstance(data["to_download"], list)
|
||||
assert "to_delete" in data
|
||||
assert isinstance(data["to_delete"], list)
|
||||
assert "to_update" in data
|
||||
assert isinstance(data["to_update"], list)
|
||||
|
||||
# All files should be new
|
||||
assert len(data["to_download"]) >= 1
|
||||
for file_info in data["to_download"]:
|
||||
# FileInfo.java fields
|
||||
assert "path" in file_info
|
||||
assert "url" in file_info
|
||||
assert "size" in file_info
|
||||
assert isinstance(file_info["size"], int)
|
||||
assert "hash" in file_info
|
||||
assert isinstance(file_info["hash"], str)
|
||||
|
||||
def test_diff_no_changes(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""Client sends correct hashes — no downloads needed."""
|
||||
pack_name = pack_fixture["name"]
|
||||
|
||||
local_files = {pack_fixture["mod_path"]: pack_fixture["mod_hash"]}
|
||||
|
||||
resp = client.post(
|
||||
f"/pack/{pack_name}/diff",
|
||||
json=local_files,
|
||||
headers=auth_headers(logged_in_user_with_pass["access_token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert len(data["to_download"]) == 0
|
||||
assert len(data["to_update"]) == 0
|
||||
assert len(data["to_delete"]) == 0
|
||||
|
||||
def test_diff_with_outdated_file(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""Client sends wrong hash — file should be in to_download + to_update."""
|
||||
pack_name = pack_fixture["name"]
|
||||
|
||||
local_files = {pack_fixture["mod_path"]: "old_wrong_hash"}
|
||||
|
||||
resp = client.post(
|
||||
f"/pack/{pack_name}/diff",
|
||||
json=local_files,
|
||||
headers=auth_headers(logged_in_user_with_pass["access_token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert len(data["to_download"]) == 1
|
||||
assert len(data["to_update"]) == 1
|
||||
assert data["to_update"][0] == pack_fixture["mod_path"]
|
||||
|
||||
def test_diff_extra_local_file(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""Client has extra file — should be in to_delete."""
|
||||
pack_name = pack_fixture["name"]
|
||||
|
||||
local_files = {"mods/removed-mod.jar": "some_hash"}
|
||||
|
||||
resp = client.post(
|
||||
f"/pack/{pack_name}/diff",
|
||||
json=local_files,
|
||||
headers=auth_headers(logged_in_user_with_pass["access_token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert "mods/removed-mod.jar" in data["to_delete"]
|
||||
|
||||
|
||||
class TestPackFileDownload:
|
||||
"""Test /pack/{name}/file/{path} — file serving."""
|
||||
|
||||
def test_pack_file_download(self, client, logged_in_user_with_pass, pack_fixture):
|
||||
"""Download a file from a pack."""
|
||||
pack_name = pack_fixture["name"]
|
||||
|
||||
resp = client.get(
|
||||
f"/pack/{pack_name}/file/{pack_fixture['mod_path']}",
|
||||
headers=auth_headers(logged_in_user_with_pass["access_token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == pack_fixture["mod_content"]
|
||||
|
||||
def test_pack_file_not_found(self, client, logged_in_user):
|
||||
resp = client.get(
|
||||
"/pack/nonexistent/file/mods/mod.jar",
|
||||
headers=auth_headers(logged_in_user["access_token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_pack_file_path_traversal_blocked(self, client, logged_in_user):
|
||||
"""Path traversal should be blocked."""
|
||||
resp = client.get(
|
||||
"/pack/somepack/file/../../../etc/passwd",
|
||||
headers=auth_headers(logged_in_user["access_token"])
|
||||
)
|
||||
assert resp.status_code in (403, 404)
|
||||
|
||||
|
||||
class TestPackPermissions:
|
||||
"""Test that packs require proper permissions (pass/role)."""
|
||||
|
||||
def test_packs_no_auth(self, client):
|
||||
resp = client.get("/packs")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_pack_diff_no_auth(self, client):
|
||||
resp = client.post("/pack/test/diff", json={})
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
def test_packs_user_without_pass(self, client, logged_in_user):
|
||||
"""User without pass should get 403 on /packs."""
|
||||
resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_pack_diff_user_without_pass(self, client, logged_in_user):
|
||||
"""User without pass should get 403 on /pack/{name}/diff."""
|
||||
resp = client.post(
|
||||
"/pack/test/diff",
|
||||
json={},
|
||||
headers=auth_headers(logged_in_user["access_token"])
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
class TestLauncherVersion:
|
||||
"""Test /launcher/version endpoint."""
|
||||
|
||||
def test_launcher_version(self, client):
|
||||
"""Should return version info."""
|
||||
resp = client.get("/launcher/version")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "version" in data or "latest" in data
|
||||
|
||||
|
||||
class TestProxyEndpoints:
|
||||
"""Test /proxy/* endpoints that ZHttpClient uses."""
|
||||
|
||||
def test_proxy_status(self, client):
|
||||
"""Proxy status works without proxy_client."""
|
||||
resp = client.get("/proxy/status")
|
||||
# May be 200 or 500 if proxy_client is None
|
||||
assert resp.status_code in (200, 500)
|
||||
|
||||
def test_proxy_fabric_versions(self, client):
|
||||
"""ZHttpClient uses this for Fabric loader versions."""
|
||||
resp = client.get("/proxy/fabric/versions/loader")
|
||||
# Works if proxy_client is set up, fails otherwise
|
||||
assert resp.status_code in (200, 500, 502, 504)
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Tests verifying server responses match client (AuthManager.java) expectations."""
|
||||
import pytest
|
||||
from tests.conftest import auth_headers
|
||||
|
||||
|
||||
class TestAuthResponseContract:
|
||||
"""Verify /auth/register and /auth/login response fields match AuthSession.java."""
|
||||
|
||||
def test_register_has_all_session_fields(self, client):
|
||||
"""Client expects: access_token, refresh_token, expires_in, uuid, username, role."""
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": "contracttest",
|
||||
"password": "ContractPass123"
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# AuthManager.AuthSession fields
|
||||
assert "access_token" in data, "Client needs access_token"
|
||||
assert "refresh_token" in data, "Client needs refresh_token"
|
||||
assert "expires_in" in data, "Client needs expires_in (int)"
|
||||
assert "uuid" in data, "Client needs uuid"
|
||||
assert "username" in data, "Client needs username"
|
||||
assert "role" in data, "Client needs role (int)"
|
||||
|
||||
# Type checks
|
||||
assert isinstance(data["access_token"], str)
|
||||
assert isinstance(data["refresh_token"], str)
|
||||
assert isinstance(data["expires_in"], int)
|
||||
assert isinstance(data["uuid"], str)
|
||||
assert isinstance(data["role"], int)
|
||||
assert data["expires_in"] > 0 # Must be positive seconds
|
||||
|
||||
def test_login_has_all_session_fields(self, client, registered_user):
|
||||
resp = client.post("/auth/login", json=registered_user)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert "expires_in" in data
|
||||
assert "uuid" in data
|
||||
assert "username" in data
|
||||
assert "role" in data
|
||||
|
||||
assert isinstance(data["expires_in"], int)
|
||||
assert isinstance(data["role"], int)
|
||||
|
||||
|
||||
class TestValidateResponseContract:
|
||||
"""Verify /auth/validate response matches client expectations."""
|
||||
|
||||
def test_validate_valid_response_fields(self, client, logged_in_user):
|
||||
"""Client checks: valid (bool), username, uuid, role."""
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": logged_in_user["username"],
|
||||
"uuid": logged_in_user["uuid"]
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert "valid" in data
|
||||
assert isinstance(data["valid"], bool)
|
||||
assert data["valid"] is True
|
||||
assert "username" in data
|
||||
assert "uuid" in data
|
||||
|
||||
def test_validate_invalid_response_fields(self, client):
|
||||
resp = client.post("/auth/validate", json={
|
||||
"username": "test",
|
||||
"uuid": "test"
|
||||
}, headers=auth_headers("bad.token"))
|
||||
assert resp.status_code == 401 # Invalid token returns 401
|
||||
|
||||
|
||||
class TestAdminMeResponseContract:
|
||||
"""Verify /admin/me response matches UserInfo.java expectations."""
|
||||
|
||||
def test_admin_me_has_all_userinfo_fields(self, client, logged_in_user):
|
||||
"""
|
||||
Client UserInfo.java expects:
|
||||
id (int), username, uuid, role (int), role_name, has_pass (bool), permissions (list)
|
||||
"""
|
||||
resp = client.get("/admin/me", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert "id" in data, "UserInfo needs id"
|
||||
assert "username" in data
|
||||
assert "uuid" in data
|
||||
assert "role" in data, "UserInfo needs role"
|
||||
assert "role_name" in data, "UserInfo needs role_name"
|
||||
assert "has_pass" in data, "UserInfo needs has_pass"
|
||||
assert "permissions" in data, "UserInfo needs permissions"
|
||||
|
||||
# Type checks
|
||||
assert isinstance(data["id"], int)
|
||||
assert isinstance(data["role"], int)
|
||||
assert isinstance(data["has_pass"], bool)
|
||||
assert isinstance(data["permissions"], list)
|
||||
assert isinstance(data["role_name"], str)
|
||||
|
||||
|
||||
class TestErrorResponseContract:
|
||||
"""Verify error responses match client extractError() parsing."""
|
||||
|
||||
def test_error_has_detail_field(self, client):
|
||||
"""Client parses json.detail (string or array with msg)."""
|
||||
resp = client.post("/auth/login", json={
|
||||
"username": "nonexistent",
|
||||
"password": "wrong"
|
||||
})
|
||||
# FastAPI returns 422 for validation errors, auth errors return 401
|
||||
assert resp.status_code in (401, 422)
|
||||
data = resp.json()
|
||||
assert "detail" in data, "Client expects 'detail' field in errors"
|
||||
assert isinstance(data["detail"], (str, list))
|
||||
|
||||
def test_validation_error_has_detail_array(self, client):
|
||||
"""FastAPI 422 returns detail as array of {loc, msg, type}."""
|
||||
resp = client.post("/auth/register", json={
|
||||
"username": "ab",
|
||||
"password": "x"
|
||||
})
|
||||
assert resp.status_code == 422
|
||||
data = resp.json()
|
||||
assert "detail" in data
|
||||
assert isinstance(data["detail"], list)
|
||||
assert "msg" in data["detail"][0]
|
||||
|
||||
|
||||
class TestPackResponseContract:
|
||||
"""Verify /packs response matches client expectations."""
|
||||
|
||||
def test_packs_response_structure(self, client, logged_in_user):
|
||||
resp = client.get("/packs", headers=auth_headers(logged_in_user["access_token"]))
|
||||
# May return 200 or 401/403 depending on auth setup
|
||||
assert resp.status_code in (200, 401, 403)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert "packs" in data
|
||||
assert isinstance(data["packs"], list)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for pass (проходка) management."""
|
||||
import pytest
|
||||
import sqlite3
|
||||
import time
|
||||
import secrets
|
||||
from tests.conftest import auth_headers
|
||||
import auth
|
||||
|
||||
|
||||
class TestPassActivate:
|
||||
"""Test /auth/pass/activate endpoint."""
|
||||
|
||||
def test_activate_valid_pass(self, client, logged_in_user):
|
||||
"""Create a pass code and activate it."""
|
||||
pass_code = f"TEST-PASS-{secrets.token_hex(4)}"
|
||||
|
||||
# Create a pass in DB (use auth.AUTH_DB which is patched by conftest)
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute(
|
||||
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 0)",
|
||||
(pass_code,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": pass_code
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "message" in data
|
||||
assert "success" in data and data["success"] is True
|
||||
|
||||
# Verify pass is now used
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
row = conn.execute("SELECT uses, activated_by FROM passes WHERE code = ?", (pass_code,)).fetchone()
|
||||
conn.close()
|
||||
assert row[0] == 1
|
||||
|
||||
def test_activate_invalid_pass(self, client, logged_in_user):
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": "NONEXISTENT-CODE"
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_activate_already_used_pass(self, client, logged_in_user):
|
||||
"""Create an already-used pass."""
|
||||
pass_code = f"USED-PASS-{secrets.token_hex(4)}"
|
||||
|
||||
conn = sqlite3.connect(str(auth.AUTH_DB))
|
||||
conn.execute(
|
||||
"INSERT INTO passes (code, is_active, max_uses, uses) VALUES (?, 1, 1, 1)",
|
||||
(pass_code,)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": pass_code
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code in (400, 404) # 400 for max uses reached, 404 for not found
|
||||
|
||||
def test_activate_pass_empty_code(self, client, logged_in_user):
|
||||
resp = client.post("/auth/pass/activate", json={
|
||||
"pass_code": ""
|
||||
}, headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestPassMyStatus:
|
||||
"""Test /auth/pass/my endpoint."""
|
||||
|
||||
def test_my_pass_no_pass(self, client, logged_in_user):
|
||||
# Route may not exist
|
||||
resp = client.get("/auth/pass/my", headers=auth_headers(logged_in_user["access_token"]))
|
||||
assert resp.status_code in (200, 404)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert "has_active" in data
|
||||
assert data["has_active"] is False
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for proxy endpoints."""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestProxyEndpoints:
|
||||
"""Test /proxy/* endpoints."""
|
||||
|
||||
def test_proxy_status(self, client):
|
||||
"""Proxy status should be accessible."""
|
||||
resp = client.get("/proxy/status")
|
||||
# May return 200 or 500 if proxy_client is None (no lifespan in tests)
|
||||
assert resp.status_code in (200, 500)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for rate limiting (TTLCache-based)."""
|
||||
import pytest
|
||||
from auth import check_rate_limit, record_login_attempt, MAX_LOGIN_ATTEMPTS, LOGIN_BLOCK_MINUTES
|
||||
|
||||
|
||||
class TestRateLimit:
|
||||
"""Test rate limiting functions."""
|
||||
|
||||
def test_no_attempts_allowed(self):
|
||||
"""Fresh IP should be allowed."""
|
||||
allowed, wait = check_rate_limit("fresh-ip")
|
||||
assert allowed is True
|
||||
assert wait is None
|
||||
|
||||
def test_single_attempt_allowed(self):
|
||||
"""One failed attempt should still be allowed."""
|
||||
ip = "single-attempt-ip"
|
||||
record_login_attempt(ip, False)
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
assert allowed is True
|
||||
|
||||
def test_max_attempts_blocks(self):
|
||||
"""MAX_LOGIN_ATTEMPTS failed attempts should block."""
|
||||
ip = "blocked-ip"
|
||||
for _ in range(MAX_LOGIN_ATTEMPTS):
|
||||
record_login_attempt(ip, False)
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
assert allowed is False
|
||||
assert wait is not None
|
||||
assert wait > 0
|
||||
# Wait should be approximately LOGIN_BLOCK_MINUTES * 60
|
||||
assert wait <= LOGIN_BLOCK_MINUTES * 60
|
||||
|
||||
def test_success_resets_attempts(self):
|
||||
"""Successful login should reset rate limit."""
|
||||
ip = "reset-ip"
|
||||
for _ in range(MAX_LOGIN_ATTEMPTS - 1):
|
||||
record_login_attempt(ip, False)
|
||||
|
||||
# One success should reset
|
||||
record_login_attempt(ip, True)
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
assert allowed is True
|
||||
assert wait is None
|
||||
|
||||
def test_success_then_fail_starts_fresh(self):
|
||||
"""After success reset, failing again should start from 1."""
|
||||
ip = "fresh-start-ip"
|
||||
record_login_attempt(ip, False)
|
||||
record_login_attempt(ip, True)
|
||||
record_login_attempt(ip, False)
|
||||
|
||||
allowed, wait = check_rate_limit(ip)
|
||||
assert allowed is True # Only 1 attempt after reset
|
||||
|
||||
def test_separate_ips_independent(self):
|
||||
"""Rate limit should be per-IP."""
|
||||
ip1 = "ip-one"
|
||||
ip2 = "ip-two"
|
||||
|
||||
for _ in range(MAX_LOGIN_ATTEMPTS):
|
||||
record_login_attempt(ip1, False)
|
||||
|
||||
allowed1, _ = check_rate_limit(ip1)
|
||||
allowed2, _ = check_rate_limit(ip2)
|
||||
|
||||
assert allowed1 is False
|
||||
assert allowed2 is True
|
||||
Reference in New Issue
Block a user