Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9ed825686 | |||
| d0b4e187c8 | |||
| f2d3de82f7 | |||
| b4431702dc | |||
| cd2cf44d9c | |||
| 8939e24e69 | |||
| c0310ed573 | |||
| c96b502ad4 | |||
| bfcffdd88d | |||
| 331fc9a863 | |||
| e347c042d5 | |||
| bb564e6e9b | |||
| 6f53002266 | |||
| 9688509df5 | |||
| efc4b086d1 | |||
| 2cdc438411 | |||
| b60e414d37 | |||
| 10ec8625b9 | |||
| f24cc078c5 | |||
| adde40d921 | |||
| 6bf6c1634a | |||
| 98462ba4a3 | |||
| 11ec84fe24 | |||
| 8b56652a73 | |||
| d7a6eb760e | |||
| c6dd215e9b |
@@ -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
|
.vscode
|
||||||
dependency-reduced-pom.xml
|
dependency-reduced-pom.xml
|
||||||
OpenJDK21U-jre_x64_windows_hotspot_21.0.6_7.zip
|
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 и выделенной оперативной памяти)
|
- Нормальных настроек (пока доступна только настройка Java и выделенной оперативной памяти)
|
||||||
- Поддержки **Forge** (в разработке)
|
- Поддержки **Forge** (в разработке)
|
||||||
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
- Поддержки Quilt, LabyMod, NeoForge и других лоадеров
|
||||||
- Раздела новостей об обновлениях Minecraft и лаунчера
|
- Раздела новостей об обновлениях Minecraft и лаунчера
|
||||||
- Выбора готовых пресетов оптимизации JVM
|
- Выбора готовых пресетов оптимизации JVM
|
||||||
|
- Кастомных модов (UI, спавнеры, DPI, карточки)
|
||||||
|
- Сайта для лаунчера и сервера
|
||||||
|
- Трекинга наигранного времени
|
||||||
|
|
||||||
## Что планируется доработать в ближайшее время
|
## Что планируется доработать в ближайшее время
|
||||||
|
|
||||||
|
- **UI мод** — переписать мод на UI: красивое главное меню, анимации, анимированный задний фон, эмбиент звуки, интерактивность, урезание ванильных элементов до используемых
|
||||||
|
- **GUI мод** — привести в единый стиль с главным меню
|
||||||
|
- **Мод на спавнеры** — кастомные спавнеры с лимитами (5-15 спавнов), отслеживание спавнов вокруг, замена на базовый спавнер при достижении предела с эффектами и звуками, данжи «временного парадокса» с процедурной генерацией этажей, минибоссы, лут
|
||||||
|
- **DPI мод** — отслеживание не-ZernMC лаунчеров, защита от форков с выпеленной проверкой, уведомления админу в Telegram с технической информацией
|
||||||
|
- **Сайт** — полноценный сайт для лаунчера и сервера (текущий «полу-живой» нуждается в полной переделке)
|
||||||
|
- **Система карточек** — дроп случайных карточек (обучена на датасете скинов CS2), просмотр, продажа, крафт, обмен между игроками, внутриигровая валюта «йоны», начисление йонов на баланс, обмен йонов на предметы, вывод йонов в отдельный предмет, анимации и эффекты
|
||||||
|
- **Web API** — OpenAPI документация, уровни доступа к API (например, получение списка игроков требует проходку)
|
||||||
|
- **Трекинг наигранного времени** — обновление каждую минуту вместо часа для нормальных графиков игроков
|
||||||
- Генерацию команды запуска Minecraft
|
- Генерацию команды запуска Minecraft
|
||||||
- Стабильную работу автообновления лаунчера
|
- Стабильную работу автообновления лаунчера
|
||||||
- Полноценные настройки
|
- Полноценные настройки
|
||||||
- Стабильность и производительность серверной части
|
- **Улучшенный античит / ClientChecker** — проверка подлинности клиента при подключении к серверу, без нужного клиента не пустит; поставляется вместе с лаунчером, не общедоступный. Хеш-проверка всех папок и файлов сборки при каждом запуске — при несовпадении одного хеша все моды переустанавливаются. Игнорируются только: логи, ресурспаки, шейдеры, сейвы, личные файлы. Защита от подмены libs и лоадеров (Meteor и аналоги), проверка целостности модов через хеши. В перспективе — защита от Mixin-атак (перехват логики других модов), сбор отчёта о текущей сборке и сравнение с базовой
|
||||||
|
- **Баг-фиксы сервера:** подключить `admin_router` в `main.py`, исправить импорты ролей (`ROLE_USER` и др. не существуют в `roles.py`), добавить эндпоинт `/auth/pass/activate`, убрать дубли импортов (`TTLCache`, `Response`)
|
||||||
- Улучшение прокси-режима
|
- Улучшение прокси-режима
|
||||||
|
- Стабильность и производительность серверной части
|
||||||
- Общую надёжность загрузки файлов с сервера
|
- Общую надёжность загрузки файлов с сервера
|
||||||
- аккаунты, проходки
|
|
||||||
|
|
||||||
## Важная информация перед использованием
|
## Важная информация перед использованием
|
||||||
|
|
||||||
@@ -39,12 +52,10 @@
|
|||||||
|
|
||||||
Лаунчер использует **текстовый интерфейс (TUI)**:
|
Лаунчер использует **текстовый интерфейс (TUI)**:
|
||||||
|
|
||||||
- `W` / `S` (или `Ц` / `Ы`) — перемещение по меню
|
- `W` / `S` (или `Ц` / `Ы`) или `↑` / `↓` — перемещение по меню
|
||||||
- `ENTER` — выбор пункта
|
- `ENTER` — выбор пункта
|
||||||
- `ESC` или пункт «Назад» — возврат назад
|
- `ESC` или пункт «Назад» — возврат назад
|
||||||
|
|
||||||
> **Важно:** Стрелки ↑/↓ могут вызывать баги и краши. Используйте только `W`/`S`.
|
|
||||||
|
|
||||||
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
||||||
|
|
||||||
### Расположение сборок
|
### Расположение сборок
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<versionInfo>
|
<versionInfo>
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
<fileVersion>${project.version}.0</fileVersion>
|
||||||
<txtFileVersion>${project.version}</txtFileVersion>
|
<txtFileVersion>${project.version}</txtFileVersion>
|
||||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
<fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
|
||||||
<productVersion>${project.version}.0</productVersion>
|
<productVersion>${project.version}.0</productVersion>
|
||||||
<txtProductVersion>${project.version}</txtProductVersion>
|
<txtProductVersion>${project.version}</txtProductVersion>
|
||||||
<productName>ZernMC Launcher</productName>
|
<productName>ZernMC Launcher</productName>
|
||||||
@@ -91,6 +91,24 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</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>
|
<properties>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||||
|
|||||||
+50
-5
@@ -6,13 +6,16 @@
|
|||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
<groupId>me.sashegdev</groupId>
|
<groupId>me.sashegdev</groupId>
|
||||||
<artifactId>ZernMCLauncher</artifactId>
|
<artifactId>ZernMCLauncher</artifactId>
|
||||||
<version>1.0.7</version>
|
<version>1.0.8</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<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>
|
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@@ -42,6 +45,11 @@
|
|||||||
<artifactId>jansi</artifactId>
|
<artifactId>jansi</artifactId>
|
||||||
<version>2.4.1</version>
|
<version>2.4.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jline</groupId>
|
||||||
|
<artifactId>jline</artifactId>
|
||||||
|
<version>3.24.1</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>me.tongfei</groupId>
|
<groupId>me.tongfei</groupId>
|
||||||
<artifactId>progressbar</artifactId>
|
<artifactId>progressbar</artifactId>
|
||||||
@@ -52,10 +60,22 @@
|
|||||||
<artifactId>commons-io</artifactId>
|
<artifactId>commons-io</artifactId>
|
||||||
<version>2.15.1</version>
|
<version>2.15.1</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.10.1</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.3</version>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
<!-- Shade Plugin -->
|
<!-- Shade Plugin -->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
@@ -76,7 +96,7 @@
|
|||||||
<Implementation-Version>${project.version}</Implementation-Version>
|
<Implementation-Version>${project.version}</Implementation-Version>
|
||||||
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||||
<Implementation-Vendor>SashegDev</Implementation-Vendor>
|
<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>
|
<Implementation-URL>https://github.com/SashegDev/launcher</Implementation-URL>
|
||||||
</manifestEntries>
|
</manifestEntries>
|
||||||
</transformer>
|
</transformer>
|
||||||
@@ -99,7 +119,7 @@
|
|||||||
<goal>launch4j</goal>
|
<goal>launch4j</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
|
<outfile>../server/builds/ZernMCLauncher-${project.version}.exe</outfile>
|
||||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
||||||
<headerType>console</headerType>
|
<headerType>console</headerType>
|
||||||
<dontWrapJar>false</dontWrapJar>
|
<dontWrapJar>false</dontWrapJar>
|
||||||
@@ -110,13 +130,13 @@
|
|||||||
<versionInfo>
|
<versionInfo>
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
<fileVersion>${project.version}.0</fileVersion>
|
||||||
<txtFileVersion>${project.version}</txtFileVersion>
|
<txtFileVersion>${project.version}</txtFileVersion>
|
||||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
<fileDescription>ZernMC Launcher — just a Minecraft launcher</fileDescription>
|
||||||
<productVersion>${project.version}.0</productVersion>
|
<productVersion>${project.version}.0</productVersion>
|
||||||
<txtProductVersion>${project.version}</txtProductVersion>
|
<txtProductVersion>${project.version}</txtProductVersion>
|
||||||
<productName>ZernMC Launcher</productName>
|
<productName>ZernMC Launcher</productName>
|
||||||
<companyName>ZernMC(SashegDev)</companyName>
|
<companyName>ZernMC(SashegDev)</companyName>
|
||||||
<internalName>ZernMCLauncher</internalName>
|
<internalName>ZernMCLauncher</internalName>
|
||||||
<originalFilename>ZernMCLauncher.exe</originalFilename>
|
<originalFilename>ZernMCLauncher-${project.version}.exe</originalFilename>
|
||||||
</versionInfo>
|
</versionInfo>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
@@ -153,4 +173,29 @@
|
|||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</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>
|
</project>
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package me.sashegdev.zernmc.launcher;
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
import me.sashegdev.zernmc.launcher.menu.*;
|
import me.sashegdev.zernmc.launcher.menu.*;
|
||||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||||
import me.sashegdev.zernmc.launcher.utils.*;
|
import me.sashegdev.zernmc.launcher.utils.*;
|
||||||
|
import me.sashegdev.zernmc.launcher.web.UIWindow;
|
||||||
|
import me.sashegdev.zernmc.launcher.web.WebServer;
|
||||||
|
|
||||||
|
import java.awt.GraphicsEnvironment;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
@@ -12,33 +16,92 @@ import java.net.http.HttpResponse;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.StandardCopyOption;
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class Main {
|
public class Main {
|
||||||
|
|
||||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
||||||
|
private static final LauncherAPI api = new LauncherAPI();
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException {
|
public static void main(String[] args) throws Exception {
|
||||||
|
boolean cliMode = Arrays.asList(args).contains("--cli") || Arrays.asList(args).contains("-c");
|
||||||
|
|
||||||
|
if (cliMode) {
|
||||||
|
runTUI(args);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
startWebUI(args);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.red("UI не запустился: " + e.getMessage()));
|
||||||
|
System.out.println(ZAnsi.yellow("Переключаюсь на режим TUI..."));
|
||||||
|
runTUI(args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void startWebUI(String[] args) throws Exception {
|
||||||
|
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||||
|
System.setProperty("file.encoding", "UTF-8");
|
||||||
|
|
||||||
|
int startPort = 8080;
|
||||||
|
for (int i = 0; i < args.length - 1; i++) {
|
||||||
|
if (args[i].equals("--port") || args[i].equals("-p")) {
|
||||||
|
startPort = Integer.parseInt(args[i + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.brightGreen("Запуск Web UI..."));
|
||||||
|
System.out.println(ZAnsi.cyan("Поиск свободного порта..."));
|
||||||
|
|
||||||
|
int port = WebServer.findFreePort(startPort);
|
||||||
|
|
||||||
|
// Запускаем WebServer в отдельном потоке
|
||||||
|
Thread serverThread = new Thread(() -> {
|
||||||
|
try {
|
||||||
|
WebServer.start(port);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("WebServer error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
serverThread.setDaemon(true);
|
||||||
|
serverThread.start();
|
||||||
|
|
||||||
|
// Даем серверу время запуститься
|
||||||
|
Thread.sleep(1000);
|
||||||
|
|
||||||
|
// Проверяем headless перед запуском JavaFX
|
||||||
|
if (java.awt.GraphicsEnvironment.isHeadless()) {
|
||||||
|
System.out.println(ZAnsi.yellow("Дисплей недоступен, переключаюсь на TUI..."));
|
||||||
|
WebServer.stop();
|
||||||
|
runTUI(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем JavaFX окно
|
||||||
|
UIWindow.start(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runTUI(String[] args) throws IOException {
|
||||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||||
System.setProperty("file.encoding", "UTF-8");
|
System.setProperty("file.encoding", "UTF-8");
|
||||||
System.setProperty("sun.err.encoding", "UTF-8");
|
System.setProperty("sun.err.encoding", "UTF-8");
|
||||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||||
java.nio.charset.Charset.defaultCharset();
|
|
||||||
ZAnsi.install();
|
ZAnsi.install();
|
||||||
|
|
||||||
System.out.print("\033[H\033[2J");
|
System.out.print("\033[H\033[2J");
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION) + ZAnsi.cyan(" [CLI mode]"));
|
||||||
|
|
||||||
//проверка всех сервисов при старте
|
// Проверка всех сервисов при старте
|
||||||
ZHttpClient.checkAllServicesOnStartup();
|
ZHttpClient.checkAllServicesOnStartup();
|
||||||
|
|
||||||
checkAndAutoUpdateLauncher();
|
checkAndAutoUpdateLauncher();
|
||||||
|
|
||||||
// === АВТОРИЗАЦИЯ ===
|
// === АВТОРИЗАЦИЯ (используем новый API) ===
|
||||||
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
||||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
var sessionResponse = api.checkSession();
|
||||||
|
|
||||||
if (!sessionRestored) {
|
if (!sessionResponse.isSuccess()) {
|
||||||
LoginMenu loginMenu = new LoginMenu();
|
LoginMenu loginMenu = new LoginMenu();
|
||||||
boolean loggedIn = loginMenu.show();
|
boolean loggedIn = loginMenu.show();
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
@@ -47,10 +110,11 @@ public class Main {
|
|||||||
System.exit(0);
|
System.exit(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
var sessionInfo = sessionResponse.getData();
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
|
||||||
}
|
}
|
||||||
// === КОНЕЦ АВТОРИЗАЦИИ ===
|
|
||||||
|
|
||||||
|
// === ГЛАВНЫЙ ЦИКЛ ===
|
||||||
try {
|
try {
|
||||||
mainLoop();
|
mainLoop();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -63,7 +127,6 @@ public class Main {
|
|||||||
|
|
||||||
private static void checkAndAutoUpdateLauncher() {
|
private static void checkAndAutoUpdateLauncher() {
|
||||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String json = ZHttpClient.getLauncherVersionInfo();
|
String json = ZHttpClient.getLauncherVersionInfo();
|
||||||
String serverVersion = extractVersion(json);
|
String serverVersion = extractVersion(json);
|
||||||
@@ -74,13 +137,11 @@ public class Main {
|
|||||||
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
|
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
|
||||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
|
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
|
||||||
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
|
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
|
||||||
|
|
||||||
performAutoUpdate(serverVersion);
|
performAutoUpdate(serverVersion);
|
||||||
restartLauncher();
|
restartLauncher();
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
||||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
@@ -109,9 +170,7 @@ public class Main {
|
|||||||
long size = Files.size(tempJar);
|
long size = Files.size(tempJar);
|
||||||
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
|
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
|
||||||
|
|
||||||
// Заменяем текущий jar
|
|
||||||
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
|
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
|
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,27 +211,73 @@ public class Main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
|
||||||
private static void mainLoop() throws Exception {
|
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) {
|
while (true) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
||||||
|
|
||||||
List<String> options = List.of(
|
List<String> options = List.of(
|
||||||
"Запустить игру",
|
"Запустить игру",
|
||||||
"Проверка обновлений",
|
"Проверка обновлений",
|
||||||
"Настройки",
|
"Настройки",
|
||||||
"Проверка подключения к серверам Zern",
|
"Проверка подключения к серверам",
|
||||||
"Выход"
|
"Выход"
|
||||||
);
|
);
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
||||||
int choice = menu.show();
|
int choice = menu.show();
|
||||||
|
|
||||||
if (choice == -1 || choice == 4) {
|
if (choice == -1 || choice == 4) {
|
||||||
System.out.print("\033[H\033[2J");
|
|
||||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (choice) {
|
switch (choice) {
|
||||||
case 0 -> new LaunchMenu().show();
|
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
|
||||||
case 1 -> new UpdateMenu().show();
|
case 1 -> new UpdateMenu().show();
|
||||||
case 2 -> new SettingsMenu().show();
|
case 2 -> new SettingsMenu().show();
|
||||||
case 3 -> new ServerCheckMenu().show();
|
case 3 -> new ServerCheckMenu().show();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api;
|
||||||
|
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
private boolean success;
|
||||||
|
private T data;
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
public ApiResponse(boolean success, T data, String error) {
|
||||||
|
this.success = success;
|
||||||
|
this.data = data;
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return new ApiResponse<>(true, data, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String error) {
|
||||||
|
return new ApiResponse<>(false, null, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
public T getData() {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getError() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.auth.AuthService;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.install.InstallService;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.launch.LaunchService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Центральный фасад для внутреннего API лаунчера.
|
||||||
|
* Используется как единая точка входа для UI и других компонентов.
|
||||||
|
*/
|
||||||
|
public class LauncherAPI {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final InstanceService instanceService;
|
||||||
|
private final LaunchService launchService;
|
||||||
|
private final InstallService installService;
|
||||||
|
|
||||||
|
public LauncherAPI() {
|
||||||
|
this.authService = new AuthService();
|
||||||
|
this.instanceService = new InstanceService();
|
||||||
|
this.launchService = new LaunchService();
|
||||||
|
this.installService = new InstallService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthService auth() {
|
||||||
|
return authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InstanceService instances() {
|
||||||
|
return instanceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LaunchService launch() {
|
||||||
|
return launchService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InstallService install() {
|
||||||
|
return installService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== Удобные методы ======================
|
||||||
|
|
||||||
|
public boolean isLoggedIn() {
|
||||||
|
return authService.isLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentUsername() {
|
||||||
|
return authService.getCurrentUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<AuthService.SessionInfo> checkSession() {
|
||||||
|
return authService.checkSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<AuthService.LoginResult> login(String username, String password) {
|
||||||
|
return authService.login(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<Boolean> logout() {
|
||||||
|
return authService.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<List<InstanceService.InstanceInfo>> getAllInstances() {
|
||||||
|
return instanceService.getAllInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<LaunchService.InstanceInfo> getLaunchInfo(String instanceName) {
|
||||||
|
return launchService.getLaunchInfo(instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<LaunchService.LaunchInfo> prepareLaunch(String instanceName) {
|
||||||
|
return launchService.prepareLaunch(instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
|
||||||
|
return launchService.launch(instanceName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api.auth;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
public ApiResponse<LoginResult> login(String username, String password) {
|
||||||
|
try {
|
||||||
|
AuthManager.AuthResult result = AuthManager.login(username, password);
|
||||||
|
if (result.success) {
|
||||||
|
LoginResult loginResult = new LoginResult(AuthManager.getUsername(), AuthManager.getAccessToken());
|
||||||
|
return ApiResponse.success(loginResult);
|
||||||
|
}
|
||||||
|
return ApiResponse.error(result.error != null ? result.error : "Неверный логин или пароль");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка авторизации: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<Boolean> logout() {
|
||||||
|
try {
|
||||||
|
AuthManager.logout();
|
||||||
|
return ApiResponse.success(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка при выходе: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<SessionInfo> checkSession() {
|
||||||
|
try {
|
||||||
|
boolean restored = AuthManager.loadSavedSession();
|
||||||
|
if (restored) {
|
||||||
|
SessionInfo info = new SessionInfo(
|
||||||
|
AuthManager.getUsername(),
|
||||||
|
AuthManager.getAccessToken(),
|
||||||
|
AuthManager.hasActivePass()
|
||||||
|
);
|
||||||
|
return ApiResponse.success(info);
|
||||||
|
}
|
||||||
|
return ApiResponse.error("Сессия не найдена");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка проверки сессии: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<Boolean> activatePass(String passCode) {
|
||||||
|
try {
|
||||||
|
String response = post("/auth/pass/activate",
|
||||||
|
"{\"code\":\"" + passCode + "\"}");
|
||||||
|
return ApiResponse.success(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка активации проходки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String post(String endpoint, String jsonBody) throws Exception {
|
||||||
|
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||||
|
java.net.URL url = new java.net.URL(fullUrl);
|
||||||
|
java.net.HttpURLConnection conn = (java.net.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");
|
||||||
|
|
||||||
|
if (AuthManager.getAccessToken() != null && !AuthManager.getAccessToken().equals("0")) {
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + AuthManager.getAccessToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
|
||||||
|
try (var os = conn.getOutputStream()) {
|
||||||
|
byte[] input = jsonBody.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
os.write(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
int statusCode = conn.getResponseCode();
|
||||||
|
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||||
|
|
||||||
|
String responseBody;
|
||||||
|
try (var scanner = new java.util.Scanner(is, java.nio.charset.StandardCharsets.UTF_8.name())) {
|
||||||
|
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.disconnect();
|
||||||
|
|
||||||
|
if (statusCode != 200) {
|
||||||
|
throw new IOException("HTTP " + statusCode + ": " + responseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLoggedIn() {
|
||||||
|
return AuthManager.isLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentUsername() {
|
||||||
|
return AuthManager.getUsername();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LoginResult {
|
||||||
|
private String username;
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
public LoginResult(String username, String token) {
|
||||||
|
this.username = username;
|
||||||
|
this.token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public String getToken() { return token; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SessionInfo {
|
||||||
|
private String username;
|
||||||
|
private String token;
|
||||||
|
private boolean passActive;
|
||||||
|
|
||||||
|
public SessionInfo(String username, String token, boolean passActive) {
|
||||||
|
this.username = username;
|
||||||
|
this.token = token;
|
||||||
|
this.passActive = passActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public boolean isPassActive() { return passActive; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api.install;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.PackDownloader;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.ServerPack;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class InstallService {
|
||||||
|
|
||||||
|
public ApiResponse<InstallResult> installZernMCPack(String packName, String instanceName) {
|
||||||
|
try {
|
||||||
|
boolean created = InstanceManager.createInstanceFolder(instanceName);
|
||||||
|
if (!created) {
|
||||||
|
return ApiResponse.error("Сборка с таким именем уже существует: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Не удалось создать директорию сборки");
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloader downloader = new PackDownloader(instance);
|
||||||
|
|
||||||
|
// Получаем список доступных сборок
|
||||||
|
List<ServerPack> availablePacks = downloader.getAvailablePacks();
|
||||||
|
|
||||||
|
// Находим нужную сборку
|
||||||
|
ServerPack selectedPack = availablePacks.stream()
|
||||||
|
.filter(p -> p.getName().equals(packName))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (selectedPack == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + packName);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean success = downloader.installOrUpdatePack(packName, selectedPack);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return ApiResponse.success(new InstallResult(
|
||||||
|
instanceName,
|
||||||
|
selectedPack.getMinecraftVersion(),
|
||||||
|
selectedPack.getLoaderType(),
|
||||||
|
selectedPack.getVersion()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
return ApiResponse.error("Не удалось установить сборку");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка установки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<UpdateCheckResult> checkForUpdates(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null || !instance.isServerPack()) {
|
||||||
|
return ApiResponse.success(new UpdateCheckResult(false, false, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloader downloader = new PackDownloader(instance);
|
||||||
|
boolean hasUpdate = downloader.checkForUpdates(instance.getServerPackName());
|
||||||
|
|
||||||
|
return ApiResponse.success(new UpdateCheckResult(
|
||||||
|
hasUpdate,
|
||||||
|
true,
|
||||||
|
instance.getServerVersion(),
|
||||||
|
hasUpdate ? instance.getServerVersion() + 1 : instance.getServerVersion()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка проверки обновлений: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<HashCheckResult> verifyHashes(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instance.isServerPack() || instance.getServerPackName() == null) {
|
||||||
|
return ApiResponse.success(new HashCheckResult(false, List.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloader downloader = new PackDownloader(instance);
|
||||||
|
Map<String, String> localFiles = downloader.scanLocalFiles();
|
||||||
|
|
||||||
|
// Отправляем хеши на сервер через diff
|
||||||
|
var diff = downloader.getDiff(instance.getServerPackName(), localFiles);
|
||||||
|
|
||||||
|
List<String> mismatched = new ArrayList<>();
|
||||||
|
for (var f : diff.getToDownload()) {
|
||||||
|
mismatched.add(f.getPath());
|
||||||
|
}
|
||||||
|
mismatched.addAll(diff.getToUpdate());
|
||||||
|
mismatched.addAll(diff.getToDelete());
|
||||||
|
|
||||||
|
boolean hasMismatches = !mismatched.isEmpty();
|
||||||
|
|
||||||
|
return ApiResponse.success(new HashCheckResult(hasMismatches, mismatched));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка проверки хешей: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<PlayTimeInfo> getPlayTime(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.isServerPack()) {
|
||||||
|
// TODO: Для ZernMC получаем время с сервера
|
||||||
|
// String response = ZHttpClient.get("/users/me/playtime?pack=" + instance.getServerPackName());
|
||||||
|
// Пока возвращаем 0 - в будущем интегрировать с сервером
|
||||||
|
return ApiResponse.success(new PlayTimeInfo(0, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для локальных сборок возвращаем 0
|
||||||
|
return ApiResponse.success(new PlayTimeInfo(0, false));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка получения времени: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int extractPlayTime(String json) {
|
||||||
|
try {
|
||||||
|
// Простой парсинг JSON
|
||||||
|
String minutes = json.replaceAll(".*\"minutes\"\\s*:\\s*(\\d+).*", "$1");
|
||||||
|
return Integer.parseInt(minutes);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class InstallResult {
|
||||||
|
private String name;
|
||||||
|
private String mcVersion;
|
||||||
|
private String loaderType;
|
||||||
|
private int serverVersion;
|
||||||
|
|
||||||
|
public InstallResult(String name, String mcVersion, String loaderType, int serverVersion) {
|
||||||
|
this.name = name;
|
||||||
|
this.mcVersion = mcVersion;
|
||||||
|
this.loaderType = loaderType;
|
||||||
|
this.serverVersion = serverVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getMcVersion() { return mcVersion; }
|
||||||
|
public String getLoaderType() { return loaderType; }
|
||||||
|
public int getServerVersion() { return serverVersion; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class UpdateCheckResult {
|
||||||
|
private boolean hasUpdate;
|
||||||
|
private boolean isServerPack;
|
||||||
|
private int currentVersion;
|
||||||
|
private int latestVersion;
|
||||||
|
|
||||||
|
public UpdateCheckResult(boolean hasUpdate, boolean isServerPack, int currentVersion, int latestVersion) {
|
||||||
|
this.hasUpdate = hasUpdate;
|
||||||
|
this.isServerPack = isServerPack;
|
||||||
|
this.currentVersion = currentVersion;
|
||||||
|
this.latestVersion = latestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isHasUpdate() { return hasUpdate; }
|
||||||
|
public boolean isServerPack() { return isServerPack; }
|
||||||
|
public int getCurrentVersion() { return currentVersion; }
|
||||||
|
public int getLatestVersion() { return latestVersion; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HashCheckResult {
|
||||||
|
private boolean hasMismatches;
|
||||||
|
private List<String> mismatchedFiles;
|
||||||
|
|
||||||
|
public HashCheckResult(boolean hasMismatches, List<String> mismatchedFiles) {
|
||||||
|
this.hasMismatches = hasMismatches;
|
||||||
|
this.mismatchedFiles = mismatchedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasMismatches() { return hasMismatches; }
|
||||||
|
public List<String> getMismatchedFiles() { return mismatchedFiles; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PlayTimeInfo {
|
||||||
|
private int totalMinutes;
|
||||||
|
private boolean fromServer;
|
||||||
|
|
||||||
|
public PlayTimeInfo(int totalMinutes, boolean fromServer) {
|
||||||
|
this.totalMinutes = totalMinutes;
|
||||||
|
this.fromServer = fromServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalMinutes() { return totalMinutes; }
|
||||||
|
public boolean isFromServer() { return fromServer; }
|
||||||
|
|
||||||
|
public String getFormattedTime() {
|
||||||
|
int hours = totalMinutes / 60;
|
||||||
|
int minutes = totalMinutes % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return hours + "ч " + minutes + "м";
|
||||||
|
}
|
||||||
|
return minutes + "м";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api.instance;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class InstanceService {
|
||||||
|
|
||||||
|
public ApiResponse<List<InstanceInfo>> getAllInstances() {
|
||||||
|
try {
|
||||||
|
List<Instance> instances = InstanceManager.getAllInstances();
|
||||||
|
List<InstanceInfo> infoList = instances.stream()
|
||||||
|
.map(this::toInstanceInfo)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ApiResponse.success(infoList);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ApiResponse.error("Ошибка получения списка сборок: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<InstanceInfo> getInstance(String name) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(name);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + name);
|
||||||
|
}
|
||||||
|
return ApiResponse.success(toInstanceInfo(instance));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка получения сборки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<InstanceInfo> createInstance(String name) {
|
||||||
|
try {
|
||||||
|
boolean created = InstanceManager.createInstanceFolder(name);
|
||||||
|
if (!created) {
|
||||||
|
return ApiResponse.error("Сборка с таким именем уже существует: " + name);
|
||||||
|
}
|
||||||
|
Instance instance = InstanceManager.getInstance(name);
|
||||||
|
return ApiResponse.success(toInstanceInfo(instance));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ApiResponse.error("Ошибка создания сборки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<Boolean> deleteInstance(String name) {
|
||||||
|
try {
|
||||||
|
boolean deleted = InstanceManager.deleteInstance(name);
|
||||||
|
if (!deleted) {
|
||||||
|
return ApiResponse.error("Не удалось удалить сборку: " + name);
|
||||||
|
}
|
||||||
|
return ApiResponse.success(true);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка удаления сборки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<Boolean> isInstanceExists(String name) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(name);
|
||||||
|
return ApiResponse.success(instance != null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка проверки сборки: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private InstanceInfo toInstanceInfo(Instance instance) {
|
||||||
|
return new InstanceInfo(
|
||||||
|
instance.getName(),
|
||||||
|
instance.getPath().toString(),
|
||||||
|
instance.getMinecraftVersion(),
|
||||||
|
instance.getLoaderType()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class InstanceInfo {
|
||||||
|
private String name;
|
||||||
|
private String path;
|
||||||
|
private String version;
|
||||||
|
private String loaderType;
|
||||||
|
|
||||||
|
public InstanceInfo(String name, String path, String version, String loaderType) {
|
||||||
|
this.name = name;
|
||||||
|
this.path = path;
|
||||||
|
this.version = version;
|
||||||
|
this.loaderType = loaderType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getPath() { return path; }
|
||||||
|
public String getVersion() { return version; }
|
||||||
|
public String getLoaderType() { return loaderType; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api.launch;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class LaunchService {
|
||||||
|
|
||||||
|
public ApiResponse<LaunchInfo> prepareLaunch(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||||
|
LaunchOptions options = new LaunchOptions();
|
||||||
|
|
||||||
|
List<String> command = builder.build(options);
|
||||||
|
|
||||||
|
LaunchInfo info = new LaunchInfo(
|
||||||
|
instanceName,
|
||||||
|
command,
|
||||||
|
instance.getPath().toString()
|
||||||
|
);
|
||||||
|
return ApiResponse.success(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка подготовки запуска: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<ProcessInfo> launch(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||||
|
LaunchOptions options = new LaunchOptions();
|
||||||
|
|
||||||
|
List<String> command = builder.build(options);
|
||||||
|
|
||||||
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
|
processBuilder.directory(instance.getPath().toFile());
|
||||||
|
processBuilder.inheritIO();
|
||||||
|
|
||||||
|
Process process = processBuilder.start();
|
||||||
|
|
||||||
|
ProcessInfo info = new ProcessInfo(
|
||||||
|
instanceName,
|
||||||
|
process.pid(),
|
||||||
|
"RUNNING"
|
||||||
|
);
|
||||||
|
return ApiResponse.success(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка запуска: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<Boolean> isReady(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
Path versionJson = instance.getPath().resolve("version.json");
|
||||||
|
boolean hasVersionJson = versionJson.toFile().exists();
|
||||||
|
|
||||||
|
return ApiResponse.success(hasVersionJson);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка проверки готовности: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ApiResponse<InstanceInfo> getLaunchInfo(String instanceName) {
|
||||||
|
try {
|
||||||
|
Instance instance = InstanceManager.getInstance(instanceName);
|
||||||
|
if (instance == null) {
|
||||||
|
return ApiResponse.error("Сборка не найдена: " + instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
InstanceInfo info = new InstanceInfo(
|
||||||
|
instance.getName(),
|
||||||
|
instance.getMinecraftVersion(),
|
||||||
|
instance.getLoaderType(),
|
||||||
|
instance.getLoaderVersion(),
|
||||||
|
instance.getAssetIndex()
|
||||||
|
);
|
||||||
|
return ApiResponse.success(info);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ApiResponse.error("Ошибка получения информации: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LaunchInfo {
|
||||||
|
private String instanceName;
|
||||||
|
private List<String> command;
|
||||||
|
private String workingDirectory;
|
||||||
|
|
||||||
|
public LaunchInfo(String instanceName, List<String> command, String workingDirectory) {
|
||||||
|
this.instanceName = instanceName;
|
||||||
|
this.command = command;
|
||||||
|
this.workingDirectory = workingDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getInstanceName() { return instanceName; }
|
||||||
|
public List<String> getCommand() { return command; }
|
||||||
|
public String getWorkingDirectory() { return workingDirectory; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ProcessInfo {
|
||||||
|
private String instanceName;
|
||||||
|
private long pid;
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
public ProcessInfo(String instanceName, long pid, String status) {
|
||||||
|
this.instanceName = instanceName;
|
||||||
|
this.pid = pid;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getInstanceName() { return instanceName; }
|
||||||
|
public long getPid() { return pid; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class InstanceInfo {
|
||||||
|
private String name;
|
||||||
|
private String minecraftVersion;
|
||||||
|
private String loaderType;
|
||||||
|
private String loaderVersion;
|
||||||
|
private String assetIndex;
|
||||||
|
|
||||||
|
public InstanceInfo(String name, String minecraftVersion, String loaderType,
|
||||||
|
String loaderVersion, String assetIndex) {
|
||||||
|
this.name = name;
|
||||||
|
this.minecraftVersion = minecraftVersion;
|
||||||
|
this.loaderType = loaderType;
|
||||||
|
this.loaderVersion = loaderVersion;
|
||||||
|
this.assetIndex = assetIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getMinecraftVersion() { return minecraftVersion; }
|
||||||
|
public String getLoaderType() { return loaderType; }
|
||||||
|
public String getLoaderVersion() { return loaderVersion; }
|
||||||
|
public String getAssetIndex() { return assetIndex; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,12 @@ import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
|||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class AuthManager {
|
public class AuthManager {
|
||||||
|
|
||||||
@@ -20,6 +23,18 @@ public class AuthManager {
|
|||||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
private static volatile AuthSession session = null;
|
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() {
|
public static boolean loadSavedSession() {
|
||||||
if (!Files.exists(AUTH_FILE)) return false;
|
if (!Files.exists(AUTH_FILE)) return false;
|
||||||
@@ -29,6 +44,8 @@ public class AuthManager {
|
|||||||
if (loaded == null || loaded.accessToken == null) return false;
|
if (loaded == null || loaded.accessToken == null) return false;
|
||||||
|
|
||||||
session = loaded;
|
session = loaded;
|
||||||
|
userInfo = fetchUserInfo();
|
||||||
|
|
||||||
if (isAccessTokenExpired()) {
|
if (isAccessTokenExpired()) {
|
||||||
return tryRefresh();
|
return tryRefresh();
|
||||||
}
|
}
|
||||||
@@ -38,6 +55,7 @@ public class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ====================== АВТОРИЗАЦИЯ ======================
|
||||||
public static AuthResult login(String username, String password) {
|
public static AuthResult login(String username, String password) {
|
||||||
return authRequest("/auth/login", username, password);
|
return authRequest("/auth/login", username, password);
|
||||||
}
|
}
|
||||||
@@ -49,17 +67,13 @@ public class AuthManager {
|
|||||||
private static AuthResult authRequest(String endpoint, String username, String password) {
|
private static AuthResult authRequest(String endpoint, String username, String password) {
|
||||||
try {
|
try {
|
||||||
String body = GSON.toJson(new LoginRequest(username, password));
|
String body = GSON.toJson(new LoginRequest(username, password));
|
||||||
|
|
||||||
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
|
|
||||||
|
|
||||||
SimpleHttpResponse resp = post(endpoint, body);
|
SimpleHttpResponse resp = post(endpoint, body);
|
||||||
|
|
||||||
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
|
|
||||||
|
|
||||||
if (resp.statusCode() == 200) {
|
if (resp.statusCode() == 200) {
|
||||||
session = GSON.fromJson(resp.body(), AuthSession.class);
|
session = GSON.fromJson(resp.body(), AuthSession.class);
|
||||||
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
||||||
saveSession();
|
saveSession();
|
||||||
|
userInfo = fetchUserInfo();
|
||||||
return AuthResult.ok();
|
return AuthResult.ok();
|
||||||
} else if (resp.statusCode() == 422) {
|
} else if (resp.statusCode() == 422) {
|
||||||
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
|
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
|
||||||
@@ -67,7 +81,6 @@ public class AuthManager {
|
|||||||
return AuthResult.fail(extractError(resp.body()));
|
return AuthResult.fail(extractError(resp.body()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
|
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
||||||
}
|
}
|
||||||
@@ -75,11 +88,12 @@ public class AuthManager {
|
|||||||
|
|
||||||
public static void logout() {
|
public static void logout() {
|
||||||
if (session != null && session.refreshToken != null) {
|
if (session != null && session.refreshToken != null) {
|
||||||
try {
|
try {
|
||||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
session = null;
|
session = null;
|
||||||
|
userInfo = null;
|
||||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,16 +127,18 @@ public class AuthManager {
|
|||||||
try {
|
try {
|
||||||
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
||||||
SimpleHttpResponse resp = post("/auth/refresh", body);
|
SimpleHttpResponse resp = post("/auth/refresh", body);
|
||||||
|
|
||||||
if (resp.statusCode() == 200) {
|
if (resp.statusCode() == 200) {
|
||||||
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
||||||
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
||||||
session = newSession;
|
session = newSession;
|
||||||
|
userInfo = fetchUserInfo();
|
||||||
saveSession();
|
saveSession();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
session = null;
|
session = null;
|
||||||
|
userInfo = null;
|
||||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||||
return false;
|
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 {
|
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
||||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
|
||||||
java.net.HttpURLConnection conn = null;
|
|
||||||
try {
|
try {
|
||||||
java.net.URL url = java.net.URI.create(fullUrl).toURL();
|
URL url = new URL(fullUrl);
|
||||||
conn = (java.net.HttpURLConnection) url.openConnection();
|
conn = (HttpURLConnection) url.openConnection();
|
||||||
|
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||||
conn.setRequestProperty("Accept", "application/json");
|
conn.setRequestProperty("Accept", "application/json");
|
||||||
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
||||||
|
conn.setRequestProperty("Connection", "close");
|
||||||
|
|
||||||
// Добавляем токен авторизации, если есть сессия
|
|
||||||
if (session != null && session.accessToken != null) {
|
if (session != null && session.accessToken != null) {
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
||||||
}
|
}
|
||||||
@@ -157,19 +236,19 @@ public class AuthManager {
|
|||||||
conn.setConnectTimeout(15000);
|
conn.setConnectTimeout(15000);
|
||||||
conn.setReadTimeout(15000);
|
conn.setReadTimeout(15000);
|
||||||
|
|
||||||
try (java.io.OutputStream os = conn.getOutputStream()) {
|
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
||||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
conn.setFixedLengthStreamingMode(input.length);
|
||||||
os.write(input, 0, input.length);
|
|
||||||
|
try (var os = conn.getOutputStream()) {
|
||||||
|
os.write(input);
|
||||||
|
os.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
int statusCode = conn.getResponseCode();
|
int statusCode = conn.getResponseCode();
|
||||||
|
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||||
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
|
|
||||||
? conn.getInputStream()
|
|
||||||
: conn.getErrorStream();
|
|
||||||
|
|
||||||
String responseBody;
|
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() : "";
|
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,19 +262,13 @@ public class AuthManager {
|
|||||||
private static String extractError(String body) {
|
private static String extractError(String body) {
|
||||||
try {
|
try {
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
|
||||||
if (json.has("detail")) {
|
if (json.has("detail")) {
|
||||||
if (json.get("detail").isJsonArray()) {
|
if (json.get("detail").isJsonArray()) {
|
||||||
return json.getAsJsonArray("detail").get(0).getAsJsonObject()
|
return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
|
||||||
.get("msg").getAsString();
|
|
||||||
}
|
}
|
||||||
return json.get("detail").getAsString();
|
return json.get("detail").getAsString();
|
||||||
}
|
}
|
||||||
if (json.has("error")) {
|
|
||||||
return json.get("error").getAsString();
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,36 +276,27 @@ public class AuthManager {
|
|||||||
if (!isLoggedIn()) return false;
|
if (!isLoggedIn()) return false;
|
||||||
try {
|
try {
|
||||||
String response = ZHttpClient.get("/auth/pass/my");
|
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) {
|
} catch (Exception e) {
|
||||||
System.err.println("Не удалось проверить проходки: " + e.getMessage());
|
System.err.println(ZAnsi.red("Не удалось проверить проходки: ") + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String activatePass(String passCode) {
|
public static String getPassStatus() {
|
||||||
|
if (!isLoggedIn()) return "Не авторизован";
|
||||||
try {
|
try {
|
||||||
String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}";
|
String response = ZHttpClient.get("/auth/pass/my");
|
||||||
SimpleHttpResponse resp = post("/auth/pass/activate", json);
|
JsonObject json = JsonParser.parseString(response).getAsJsonObject();
|
||||||
|
boolean hasActive = json.has("has_active") && json.get("has_active").getAsBoolean();
|
||||||
System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode()));
|
return hasActive ? "Есть активная проходка" : "Проходка отсутствует";
|
||||||
|
|
||||||
if (resp.statusCode() == 200) {
|
|
||||||
return "Проходка успешно активирована!";
|
|
||||||
} else if (resp.statusCode() == 401) {
|
|
||||||
return "Ошибка: Требуется авторизация. Перезайдите в аккаунт.";
|
|
||||||
} else {
|
|
||||||
String error = extractError(resp.body());
|
|
||||||
return "Ошибка: " + error;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
return "Ошибка проверки";
|
||||||
return "Ошибка соединения: " + e.getMessage();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
||||||
|
|
||||||
public static class AuthSession {
|
public static class AuthSession {
|
||||||
@SerializedName("access_token") public String accessToken;
|
@SerializedName("access_token") public String accessToken;
|
||||||
@SerializedName("refresh_token") public String refreshToken;
|
@SerializedName("refresh_token") public String refreshToken;
|
||||||
@@ -240,12 +304,30 @@ public class AuthManager {
|
|||||||
public transient long expiresAt;
|
public transient long expiresAt;
|
||||||
public String username;
|
public String username;
|
||||||
public String uuid;
|
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 {
|
private static class LoginRequest {
|
||||||
final String username;
|
final String username;
|
||||||
final String password;
|
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 {
|
public static class AuthResult {
|
||||||
@@ -261,12 +343,12 @@ public class AuthManager {
|
|||||||
class SimpleHttpResponse {
|
class SimpleHttpResponse {
|
||||||
final int statusCode;
|
final int statusCode;
|
||||||
final String body;
|
final String body;
|
||||||
|
|
||||||
SimpleHttpResponse(int statusCode, String body) {
|
SimpleHttpResponse(int statusCode, String body) {
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
this.body = body;
|
this.body = body;
|
||||||
}
|
}
|
||||||
|
|
||||||
int statusCode() { return statusCode; }
|
int statusCode() { return statusCode; }
|
||||||
String body() { return body; }
|
String body() { return body; }
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -148,14 +148,54 @@ public class LoginMenu {
|
|||||||
* Читаем пароль — стараемся скрыть вывод через Console,
|
* Читаем пароль — стараемся скрыть вывод через Console,
|
||||||
* если недоступно (IDE/терминал без TTY) — читаем обычным способом.
|
* если недоступно (IDE/терминал без TTY) — читаем обычным способом.
|
||||||
*/
|
*/
|
||||||
private String readPassword(String prompt) {
|
private String readPassword(String prompt) throws IOException {
|
||||||
java.io.Console console = System.console();
|
org.jline.terminal.Terminal passTerminal = org.jline.terminal.TerminalBuilder.builder()
|
||||||
if (console != null) {
|
.system(true)
|
||||||
char[] chars = console.readPassword(prompt);
|
.jna(true)
|
||||||
return chars != null ? new String(chars) : "";
|
.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() {
|
private void printBanner() {
|
||||||
|
|||||||
@@ -16,71 +16,83 @@ import java.util.List;
|
|||||||
public class ServerCheckMenu {
|
public class ServerCheckMenu {
|
||||||
|
|
||||||
public void show() throws IOException {
|
public void show() throws IOException {
|
||||||
List<String> options = List.of(
|
while (true) {
|
||||||
"Проверить подключение к ZernMC серверу",
|
ConsoleUtils.clearScreen();
|
||||||
"Проверить доступ к Mojang (Minecraft)",
|
System.out.println(ZAnsi.header("Диагностика подключения"));
|
||||||
"Проверить доступ к Fabric Meta",
|
|
||||||
"Назад в главное меню"
|
|
||||||
);
|
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Диагностика подключения", options);
|
List<String> options = List.of(
|
||||||
int choice = menu.show();
|
"Проверить подключение к 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) {
|
ConsoleUtils.clearScreen();
|
||||||
case 0 -> checkZernServer();
|
|
||||||
case 1 -> checkMojang();
|
switch (choice) {
|
||||||
case 2 -> checkFabric();
|
case 0 -> checkZernServer();
|
||||||
|
case 1 -> checkMojang();
|
||||||
|
case 2 -> checkFabric();
|
||||||
|
case 3 -> checkForge();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkZernServer() {
|
private void checkZernServer() {
|
||||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String response = ZHttpClient.get("/health");
|
String response = ZHttpClient.get("/health");
|
||||||
System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!"));
|
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
|
||||||
System.out.println("Ответ: " + response);
|
System.out.println(ZAnsi.white("Ответ сервера: ") + response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу"));
|
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
|
||||||
System.out.println("Ошибка: " + e.getMessage());
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkMojang() {
|
private void checkMojang() {
|
||||||
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpClient client = HttpClient.newBuilder()
|
HttpClient client = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(8))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
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()
|
.GET()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
System.out.println(ZAnsi.brightGreen("Mojang доступен"));
|
System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode()));
|
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.brightRed("Нет доступа к Mojang"));
|
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
|
||||||
System.out.println("Ошибка: " + e.getMessage());
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkFabric() {
|
private void checkFabric() {
|
||||||
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpClient client = HttpClient.newBuilder()
|
HttpClient client = HttpClient.newBuilder()
|
||||||
.connectTimeout(Duration.ofSeconds(8))
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
@@ -91,13 +103,39 @@ public class ServerCheckMenu {
|
|||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
System.out.println(ZAnsi.brightGreen("Fabric Meta доступен"));
|
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
|
||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode()));
|
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta"));
|
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
|
||||||
System.out.println("Ошибка: " + e.getMessage());
|
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 final Path path;
|
||||||
|
|
||||||
private String minecraftVersion;
|
private String minecraftVersion;
|
||||||
private String loaderType; // vanilla, fabric, forge
|
private String loaderType; // vanilla, fabric, forge, neoforge
|
||||||
private String loaderVersion;
|
private String loaderVersion;
|
||||||
private String assetIndex;
|
private String assetIndex;
|
||||||
private boolean isServerPack; // флаг, что это сборка с сервера
|
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.FabricInstaller;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.installer.ForgeInstaller;
|
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.installer.VersionInstaller;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
@@ -41,6 +42,11 @@ public class MinecraftLib {
|
|||||||
return installer.install(minecraftVersion, forgeVersion);
|
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 {
|
public boolean installFabric(String minecraftVersion, String loaderVersion) throws Exception {
|
||||||
FabricInstaller installer = new FabricInstaller(instance);
|
FabricInstaller installer = new FabricInstaller(instance);
|
||||||
boolean success = installer.install(minecraftVersion, loaderVersion);
|
boolean success = installer.install(minecraftVersion, loaderVersion);
|
||||||
@@ -76,8 +82,17 @@ public class MinecraftLib {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
} else if ("forge".equalsIgnoreCase(loaderType)) {
|
||||||
System.out.println(ZAnsi.yellow("Forge пока не поддерживается"));
|
boolean forgeInstalled = installForge(minecraftVersion, loaderVersion);
|
||||||
return false;
|
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 и скачивание модов
|
// 3. В будущем здесь будет diff и скачивание модов
|
||||||
@@ -129,7 +144,8 @@ public class MinecraftLib {
|
|||||||
try (var stream = Files.walk(versionsDir)) {
|
try (var stream = Files.walk(versionsDir)) {
|
||||||
stream.filter(Files::isDirectory)
|
stream.filter(Files::isDirectory)
|
||||||
.filter(dir -> dir.getFileName().toString().contains("fabric-loader") ||
|
.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))
|
.filter(dir -> !dir.getFileName().toString().contains(keepVersion))
|
||||||
.forEach(this::safeDeleteDirectory);
|
.forEach(this::safeDeleteDirectory);
|
||||||
}
|
}
|
||||||
@@ -163,6 +179,8 @@ public class MinecraftLib {
|
|||||||
deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer);
|
deleteAllExcept(libraries.resolve("net/fabricmc/fabric-loader"), currentLoaderVer);
|
||||||
} else if ("forge".equals(loaderType)) {
|
} else if ("forge".equals(loaderType)) {
|
||||||
deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer);
|
deleteAllExcept(libraries.resolve("net/minecraftforge/forge"), currentLoaderVer);
|
||||||
|
} else if ("neoforge".equals(loaderType)) {
|
||||||
|
deleteAllExcept(libraries.resolve("net/neoforged/neoforge"), currentLoaderVer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Также чистим versions/ от старых fabric/forge версий
|
// Также чистим versions/ от старых fabric/forge версий
|
||||||
|
|||||||
+111
-88
@@ -6,6 +6,8 @@ import com.google.gson.JsonArray;
|
|||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
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.ProgressBar;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
@@ -27,7 +29,7 @@ public class PackDownloader {
|
|||||||
private final Instance instance;
|
private final Instance instance;
|
||||||
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
private final Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
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) {
|
public PackDownloader(Instance instance) {
|
||||||
this.instance = instance;
|
this.instance = instance;
|
||||||
@@ -37,73 +39,89 @@ public class PackDownloader {
|
|||||||
* Получить список доступных паков с сервера
|
* Получить список доступных паков с сервера
|
||||||
*/
|
*/
|
||||||
public List<ServerPack> getAvailablePacks() throws Exception {
|
public List<ServerPack> getAvailablePacks() throws Exception {
|
||||||
String response = ZHttpClient.get("/packs");
|
String accessToken = AuthManager.getAccessToken();
|
||||||
|
if (accessToken == null) {
|
||||||
// Для отладки - выведем ответ сервера
|
throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
|
||||||
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<>();
|
|
||||||
}
|
}
|
||||||
|
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");
|
JsonArray packsArray = root.getAsJsonArray("packs");
|
||||||
List<ServerPack> result = new ArrayList<>();
|
List<ServerPack> result = new ArrayList<>();
|
||||||
|
|
||||||
for (JsonElement elem : packsArray) {
|
for (JsonElement elem : packsArray) {
|
||||||
JsonObject pack = elem.getAsJsonObject();
|
JsonObject pack = elem.getAsJsonObject();
|
||||||
|
|
||||||
// Пропускаем паки с ошибками
|
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
|
||||||
if (pack.has("error")) {
|
|
||||||
System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString()));
|
|
||||||
continue;
|
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 {
|
try {
|
||||||
// Пробуем получить name или pack_name (разные форматы)
|
String name = pack.get("name").getAsString();
|
||||||
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; // Пропускаем если нет имени
|
|
||||||
}
|
|
||||||
|
|
||||||
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
||||||
|
|
||||||
// Получаем остальные поля (могут отсутствовать)
|
|
||||||
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
|
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 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;
|
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
|
||||||
|
|
||||||
// Парсим дату, если есть
|
|
||||||
LocalDateTime updatedAt = null;
|
LocalDateTime updatedAt = null;
|
||||||
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
||||||
try {
|
try {
|
||||||
updatedAt = parseDateTime(pack.get("updated_at").getAsString());
|
updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(),
|
||||||
} catch (Exception e) {
|
DateTimeFormatter.ISO_DATE_TIME);
|
||||||
// Игнорируем ошибки парсинга даты
|
} catch (Exception ignored) {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.add(new ServerPack(name, version, minecraftVersion,
|
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||||
loaderType, loaderVersion, updatedAt, filesCount));
|
loaderVersion, updatedAt, filesCount));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage()));
|
System.err.println("Ошибка парсинга пака: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +157,12 @@ public class PackDownloader {
|
|||||||
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
System.err.println(ZAnsi.brightRed("Не удалось установить Fabric"));
|
||||||
return false;
|
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())) {
|
} else if ("forge".equalsIgnoreCase(manifest.getLoaderType())) {
|
||||||
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
boolean success = lib.installForge(manifest.getMinecraftVersion(), manifest.getLoaderVersion());
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -248,7 +272,7 @@ public class PackDownloader {
|
|||||||
/**
|
/**
|
||||||
* Сканирование локальных файлов и вычисление хешей
|
* Сканирование локальных файлов и вычисление хешей
|
||||||
*/
|
*/
|
||||||
private Map<String, String> scanLocalFiles() throws IOException {
|
public Map<String, String> scanLocalFiles() throws IOException {
|
||||||
Map<String, String> files = new HashMap<>();
|
Map<String, String> files = new HashMap<>();
|
||||||
Path instancePath = instance.getPath();
|
Path instancePath = instance.getPath();
|
||||||
|
|
||||||
@@ -288,26 +312,23 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отправить diff запрос на сервер
|
* Отправить diff запрос на сервер (получить список файлов для обновления)
|
||||||
*/
|
*/
|
||||||
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
public DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
||||||
String json = gson.toJson(localFiles);
|
String json = gson.toJson(localFiles);
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName));
|
// Получаем токен авторизации
|
||||||
System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт"));
|
String accessToken = AuthManager.getAccessToken();
|
||||||
System.out.println(ZAnsi.cyan("JSON тело: " + json));
|
if (accessToken == null) {
|
||||||
|
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
|
||||||
String baseUrl = ZHttpClient.getBaseUrl();
|
|
||||||
if (baseUrl.endsWith("/")) {
|
|
||||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
|
||||||
}
|
}
|
||||||
String url = baseUrl + "/pack/" + packName + "/diff";
|
if (!AuthManager.canDownloadPacks()) {
|
||||||
|
throw new IOException("Для скачивания сборок требуется активная проходка");
|
||||||
System.out.println(ZAnsi.cyan("URL: " + url));
|
}
|
||||||
|
|
||||||
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding
|
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
|
||||||
// РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient
|
|
||||||
|
// Используем HttpURLConnection для полного контроля
|
||||||
java.net.HttpURLConnection connection = null;
|
java.net.HttpURLConnection connection = null;
|
||||||
try {
|
try {
|
||||||
java.net.URL urlObj = new java.net.URL(url);
|
java.net.URL urlObj = new java.net.URL(url);
|
||||||
@@ -315,21 +336,21 @@ public class PackDownloader {
|
|||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "application/json");
|
connection.setRequestProperty("Accept", "application/json");
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
|
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(30000);
|
connection.setConnectTimeout(30000);
|
||||||
connection.setReadTimeout(30000);
|
connection.setReadTimeout(30000);
|
||||||
|
|
||||||
// Отправляем JSON
|
// Отправляем JSON
|
||||||
try (java.io.OutputStream os = connection.getOutputStream()) {
|
try (java.io.OutputStream os = connection.getOutputStream()) {
|
||||||
byte[] input = json.getBytes("UTF-8");
|
byte[] input = json.getBytes("UTF-8");
|
||||||
os.write(input, 0, input.length);
|
os.write(input, 0, input.length);
|
||||||
os.flush();
|
os.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
int responseCode = connection.getResponseCode();
|
int responseCode = connection.getResponseCode();
|
||||||
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
|
|
||||||
|
|
||||||
// Читаем ответ
|
// Читаем ответ
|
||||||
StringBuilder response = new StringBuilder();
|
StringBuilder response = new StringBuilder();
|
||||||
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||||
@@ -339,16 +360,19 @@ public class PackDownloader {
|
|||||||
response.append(line);
|
response.append(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String responseBody = response.toString();
|
String responseBody = response.toString();
|
||||||
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
|
|
||||||
|
if (responseCode == 403) {
|
||||||
if (responseCode != 200) {
|
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
|
||||||
throw new IOException("HTTP " + responseCode + ": " + responseBody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (responseCode != 200) {
|
||||||
|
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
|
||||||
|
}
|
||||||
|
|
||||||
return gson.fromJson(responseBody, DiffResponse.class);
|
return gson.fromJson(responseBody, DiffResponse.class);
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
if (connection != null) {
|
if (connection != null) {
|
||||||
connection.disconnect();
|
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 (скачать новые файлы, удалить старые)
|
* Применить diff (скачать новые файлы, удалить старые)
|
||||||
*/
|
*/
|
||||||
@@ -486,17 +520,6 @@ public class PackDownloader {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Парсинг даты из строки
|
|
||||||
*/
|
|
||||||
private LocalDateTime parseDateTime(String dateTimeStr) {
|
|
||||||
try {
|
|
||||||
return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== Вложенные классы ======================
|
// ====================== Вложенные классы ======================
|
||||||
|
|
||||||
public static class PackManifest {
|
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.Instance;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
public class LaunchCommandBuilder {
|
public class LaunchCommandBuilder {
|
||||||
|
|
||||||
@@ -22,105 +25,266 @@ public class LaunchCommandBuilder {
|
|||||||
|
|
||||||
List<String> command = new ArrayList<>();
|
List<String> command = new ArrayList<>();
|
||||||
|
|
||||||
// 1. Путь к Java
|
String javaPath = "java";
|
||||||
String javaPath = getJavaPath();
|
|
||||||
command.add(javaPath);
|
command.add(javaPath);
|
||||||
|
|
||||||
// 2. JVM аргументы
|
|
||||||
command.addAll(getJvmArguments(options));
|
command.addAll(getJvmArguments(options));
|
||||||
|
|
||||||
// 3. Natives
|
|
||||||
Path nativesDir = instance.getPath().resolve("natives");
|
Path nativesDir = instance.getPath().resolve("natives");
|
||||||
if (!Files.exists(nativesDir)) {
|
if (!Files.exists(nativesDir)) {
|
||||||
Files.createDirectories(nativesDir);
|
Files.createDirectories(nativesDir);
|
||||||
}
|
}
|
||||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||||
|
|
||||||
String loaderType = instance.getLoaderType().toLowerCase();
|
VersionManifest manifest = resolveVersionManifest();
|
||||||
|
if (manifest != null) {
|
||||||
if ("forge".equals(loaderType)) {
|
|
||||||
command.addAll(getForgeJvmArguments());
|
|
||||||
command.add("-cp");
|
command.add("-cp");
|
||||||
command.add(buildForgeClasspath());
|
command.add(buildClasspathFromManifest(manifest));
|
||||||
command.add("cpw.mods.modlauncher.Launcher");
|
|
||||||
command.addAll(getForgeArguments(options));
|
String mainClass = resolveMainClass(manifest);
|
||||||
|
command.add(mainClass);
|
||||||
|
|
||||||
|
command.addAll(resolveGameArguments(manifest, options));
|
||||||
} else {
|
} else {
|
||||||
command.add("-cp");
|
command.add("-cp");
|
||||||
command.add(buildClasspath());
|
command.add(buildVanillaClasspath());
|
||||||
command.add(getMainClass());
|
command.add(getVanillaMainClass());
|
||||||
command.addAll(getMinecraftArguments(options));
|
command.addAll(getVanillaGameArguments(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getJavaPath() {
|
private VersionManifest resolveVersionManifest() {
|
||||||
return "java";
|
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) {
|
private Path findVersionJson() {
|
||||||
List<String> jvmArgs = new ArrayList<>();
|
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;
|
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
|
||||||
jvmArgs.add("-Xmx" + ramMB + "M");
|
String[] candidates = {
|
||||||
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
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");
|
try {
|
||||||
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
if (Files.exists(versionsDir)) {
|
||||||
jvmArgs.add("-XX:G1NewSizePercent=20");
|
try (var stream = Files.list(versionsDir)) {
|
||||||
jvmArgs.add("-XX:G1ReservePercent=20");
|
return stream
|
||||||
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
.filter(Files::isDirectory)
|
||||||
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
.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();
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
if ("forge".equals(loaderType)) {
|
||||||
if ("fabric".equals(loaderType)) {
|
vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
} else if ("neoforge".equals(loaderType)) {
|
||||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||||
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
vars.put("fml.neoForgeGroup", "net.neoforged");
|
||||||
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 (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
return vars;
|
||||||
jvmArgs.addAll(options.getExtraJvmArgs());
|
}
|
||||||
|
|
||||||
|
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 result;
|
||||||
return jvmArgs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<String> getForgeJvmArguments() {
|
private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
|
||||||
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 {
|
|
||||||
List<String> paths = new ArrayList<>();
|
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();
|
String versionId = getVersionId();
|
||||||
|
|
||||||
Path versionJar = instance.getPath()
|
Path versionJar = instance.getPath()
|
||||||
.resolve("versions")
|
.resolve("versions")
|
||||||
.resolve(versionId)
|
.resolve(versionId)
|
||||||
@@ -152,222 +316,111 @@ public class LaunchCommandBuilder {
|
|||||||
return String.join(separator, paths);
|
return String.join(separator, paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildForgeClasspath() throws Exception {
|
private Path findVersionJar() {
|
||||||
List<String> paths = new ArrayList<>();
|
|
||||||
|
|
||||||
String versionId = getVersionId();
|
String versionId = getVersionId();
|
||||||
String mcVersion = instance.getMinecraftVersion();
|
Path versionsDir = instance.getPath().resolve("versions");
|
||||||
String forgeVersion = instance.getLoaderVersion();
|
|
||||||
|
|
||||||
Path librariesDir = instance.getPath().resolve("libraries");
|
Path[] candidates = {
|
||||||
if (Files.exists(librariesDir)) {
|
versionsDir.resolve(versionId).resolve(versionId + ".jar"),
|
||||||
try (var stream = Files.walk(librariesDir)) {
|
versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar")
|
||||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
};
|
||||||
.map(p -> p.toAbsolutePath().toString())
|
|
||||||
.forEach(paths::add);
|
for (Path candidate : candidates) {
|
||||||
|
if (Files.exists(candidate)) {
|
||||||
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Path versionJar = instance.getPath()
|
try {
|
||||||
.resolve("versions")
|
if (Files.exists(versionsDir)) {
|
||||||
.resolve(versionId)
|
try (var stream = Files.list(versionsDir)) {
|
||||||
.resolve(versionId + ".jar");
|
return stream
|
||||||
if (Files.exists(versionJar)) {
|
.filter(Files::isDirectory)
|
||||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||||
} else {
|
dir.getFileName().toString().contains("neoforge"))
|
||||||
Path vanillaJar = instance.getPath()
|
.filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion()))
|
||||||
.resolve("versions")
|
.findFirst()
|
||||||
.resolve(mcVersion)
|
.map(dir -> dir.resolve(dir.getFileName().toString() + ".jar"))
|
||||||
.resolve(mcVersion + ".jar");
|
.filter(Files::exists)
|
||||||
if (Files.exists(vanillaJar)) {
|
.orElse(null);
|
||||||
paths.add(0, vanillaJar.toAbsolutePath().toString());
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
Path forgeUniversal = instance.getPath()
|
return null;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
|
||||||
if ("fabric".equals(loaderType)) {
|
if ("fabric".equals(loaderType)) {
|
||||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||||
}
|
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||||
else if ("forge".equals(loaderType)) {
|
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||||
return "cpw.mods.modlauncher.Launcher";
|
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
||||||
}
|
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||||
else {
|
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||||
return "net.minecraft.client.main.Main";
|
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();
|
printPagedMenu();
|
||||||
int key = terminal.reader().read();
|
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();
|
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();
|
selected = (selected + 1) % options.size();
|
||||||
}
|
}
|
||||||
else if (key == 13 || key == 10) { // Enter
|
else if (key == 13 || key == 10) { // Enter
|
||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
else if (key == 27) { // Esc
|
else if (key == 27) { // Esc or arrow escape seq
|
||||||
return -1;
|
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 {
|
} finally {
|
||||||
@@ -83,7 +96,7 @@ public class ArrowMenu {
|
|||||||
|
|
||||||
// Подсказка внизу (фиксированная)
|
// Подсказка внизу (фиксированная)
|
||||||
sb.append("\n")
|
sb.append("\n")
|
||||||
.append(ZAnsi.white("W/S (Ц/Ы) - перемещение | Enter - выбрать | Esc - назад"));
|
.append(ZAnsi.white("W/S (Ц/Ы) или ↑/↓ - перемещение | Enter - выбрать | Esc - назад"));
|
||||||
|
|
||||||
System.out.print(sb);
|
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_DIR = Path.of(System.getProperty("user.home"), ".zernmc");
|
||||||
private static final Path CONFIG_FILE = CONFIG_DIR.resolve("launcher.properties");
|
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();
|
private static final Properties props = new Properties();
|
||||||
|
|
||||||
// Настройки
|
// Настройки
|
||||||
@@ -83,6 +85,14 @@ public class Config {
|
|||||||
return maxMemory;
|
return maxMemory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean isZernMCBuild() {
|
||||||
|
return "zernmc".equalsIgnoreCase(BUILD_PROFILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isGlobalBuild() {
|
||||||
|
return !isZernMCBuild();
|
||||||
|
}
|
||||||
|
|
||||||
public static void setMaxMemory(int memory) {
|
public static void setMaxMemory(int memory) {
|
||||||
// Защита от слишком маленьких/больших значений
|
// Защита от слишком маленьких/больших значений
|
||||||
if (memory < 1024) memory = 1536;
|
if (memory < 1024) memory = 1536;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String readLine(String prompt) {
|
public static String readLine(String prompt) {
|
||||||
|
flushInput(); // Очищаем буфер
|
||||||
System.out.print(prompt);
|
System.out.print(prompt);
|
||||||
return scanner.nextLine().trim();
|
return scanner.nextLine().trim();
|
||||||
}
|
}
|
||||||
@@ -79,4 +80,18 @@ public class Input {
|
|||||||
public static void close() {
|
public static void close() {
|
||||||
scanner.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();
|
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) {
|
public static String yellow(String text) {
|
||||||
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
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();
|
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) {
|
public static String white(String text) {
|
||||||
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
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();
|
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) {
|
public static String bold(String text) {
|
||||||
return Ansi.ansi().bold().a(text).reset().toString();
|
return Ansi.ansi().bold().a(text).reset().toString();
|
||||||
}
|
}
|
||||||
@@ -64,17 +110,73 @@ public class ZAnsi {
|
|||||||
return Ansi.ansi().reset().toString();
|
return Ansi.ansi().reset().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Комбинированные удобные методы
|
// === Комбинированные удобные методы ===
|
||||||
public static String header(String text) {
|
public static String header(String text) {
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
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) {
|
public static String selected(String text) {
|
||||||
return Ansi.ansi()
|
return Ansi.ansi()
|
||||||
.bgBright(Ansi.Color.WHITE)
|
.bgBright(Ansi.Color.WHITE)
|
||||||
.fgBlack()
|
.fg(Ansi.Color.BLACK)
|
||||||
|
.bold()
|
||||||
.a(" > " + text + " ")
|
.a(" > " + text + " ")
|
||||||
.reset()
|
.reset()
|
||||||
.toString();
|
.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.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
@@ -25,20 +27,33 @@ public class ZHttpClient {
|
|||||||
.version(HttpClient.Version.HTTP_1_1)
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
.build();
|
.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 useProxyMode = new AtomicBoolean(false);
|
||||||
private static final AtomicBoolean proxyTested = 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 {
|
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_META("https://meta.fabricmc.net", false),
|
||||||
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
FABRIC_MAVEN("https://maven.fabricmc.net", false),
|
||||||
MOJANG_META("https://piston-meta.mojang.com", false),
|
MOJANG_META("https://piston-meta.mojang.com", false),
|
||||||
MOJANG_RESOURCES("https://resources.download.minecraft.net", false),
|
MOJANG_RESOURCES("https://resources.download.minecraft.net", false),
|
||||||
FORGE_MAVEN("https://maven.minecraftforge.net", false),
|
FORGE_MAVEN("https://maven.minecraftforge.net", false),
|
||||||
|
NEOFORGE_MAVEN("https://maven.neoforged.net", false),
|
||||||
GOOGLE("https://google.com", false),
|
GOOGLE("https://google.com", false),
|
||||||
CLOUDFLARE("https://cloudflare.com", false);
|
CLOUDFLARE("https://cloudflare.com", false);
|
||||||
|
|
||||||
@@ -92,7 +107,8 @@ public class ZHttpClient {
|
|||||||
ServiceType.FABRIC_MAVEN,
|
ServiceType.FABRIC_MAVEN,
|
||||||
ServiceType.MOJANG_META,
|
ServiceType.MOJANG_META,
|
||||||
ServiceType.MOJANG_RESOURCES,
|
ServiceType.MOJANG_RESOURCES,
|
||||||
ServiceType.FORGE_MAVEN
|
ServiceType.FORGE_MAVEN,
|
||||||
|
ServiceType.NEOFORGE_MAVEN
|
||||||
);
|
);
|
||||||
|
|
||||||
for (ServiceType service : servicesToCheck) {
|
for (ServiceType service : servicesToCheck) {
|
||||||
@@ -223,6 +239,7 @@ public class ZHttpClient {
|
|||||||
return ServiceType.MOJANG_META;
|
return ServiceType.MOJANG_META;
|
||||||
if (url.contains("resources.download.minecraft.net")) return ServiceType.MOJANG_RESOURCES;
|
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.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("google.com")) return ServiceType.GOOGLE;
|
||||||
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
|
if (url.contains("cloudflare.com")) return ServiceType.CLOUDFLARE;
|
||||||
return null;
|
return null;
|
||||||
@@ -380,13 +397,19 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(BASE_URL + endpoint))
|
.uri(URI.create(BASE_URL + endpoint))
|
||||||
.timeout(Duration.ofSeconds(15))
|
.timeout(Duration.ofSeconds(15))
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
.GET()
|
.GET();
|
||||||
.build();
|
|
||||||
|
|
||||||
|
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
|
||||||
|
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());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
@@ -401,19 +424,25 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
private static String proxyGet(String endpoint) throws IOException {
|
private static String proxyGet(String endpoint) throws IOException {
|
||||||
try {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
|
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
|
||||||
.timeout(Duration.ofSeconds(30))
|
.timeout(Duration.ofSeconds(30))
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
.GET()
|
.GET();
|
||||||
.build();
|
|
||||||
|
// ===== ДОБАВИТЬ ТОКЕН АВТОРИЗАЦИИ =====
|
||||||
|
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());
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
if (response.statusCode() != 200) {
|
||||||
throw new IOException("HTTP " + response.statusCode());
|
throw new IOException("HTTP " + response.statusCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
proxySuccessCount++;
|
proxySuccessCount++;
|
||||||
return response.body();
|
return response.body();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -479,10 +508,6 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
|
// ====================== ВСПОМОГАТЕЛЬНЫЕ ======================
|
||||||
|
|
||||||
public static String getBaseUrl() {
|
|
||||||
return BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
|
public static String getLauncherVersionInfo() throws IOException, InterruptedException {
|
||||||
return get("/launcher/version");
|
return get("/launcher/version");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.web;
|
||||||
|
|
||||||
|
import java.awt.GraphicsEnvironment;
|
||||||
|
import javafx.application.Application;
|
||||||
|
import javafx.concurrent.Worker;
|
||||||
|
import javafx.geometry.Rectangle2D;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.web.WebEngine;
|
||||||
|
import javafx.scene.web.WebView;
|
||||||
|
import javafx.stage.Screen;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
import javafx.stage.StageStyle;
|
||||||
|
|
||||||
|
public class UIWindow extends Application {
|
||||||
|
|
||||||
|
private static String url;
|
||||||
|
private static int port;
|
||||||
|
|
||||||
|
public static void start(int port) {
|
||||||
|
// Backup проверка headless
|
||||||
|
if (java.awt.GraphicsEnvironment.isHeadless()) {
|
||||||
|
throw new RuntimeException("Headless environment - no display available");
|
||||||
|
}
|
||||||
|
|
||||||
|
UIWindow.port = port;
|
||||||
|
UIWindow.url = "http://localhost:" + port;
|
||||||
|
Application.launch(UIWindow.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Stage stage) {
|
||||||
|
stage.setTitle("ZernMC Launcher");
|
||||||
|
stage.initStyle(StageStyle.UNDECORATED);
|
||||||
|
|
||||||
|
WebView webView = new WebView();
|
||||||
|
WebEngine webEngine = webView.getEngine();
|
||||||
|
|
||||||
|
webEngine.load(url);
|
||||||
|
|
||||||
|
webEngine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
|
||||||
|
if (newState == Worker.State.FAILED) {
|
||||||
|
System.err.println("Failed to load: " + url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Scene scene = new Scene(webView);
|
||||||
|
stage.setScene(scene);
|
||||||
|
|
||||||
|
Rectangle2D screenBounds = Screen.getPrimary().getVisualBounds();
|
||||||
|
double screenWidth = screenBounds.getWidth();
|
||||||
|
double screenHeight = screenBounds.getHeight();
|
||||||
|
|
||||||
|
double windowWidth = Math.min(1200, screenWidth * 0.8);
|
||||||
|
double windowHeight = Math.min(800, screenHeight * 0.85);
|
||||||
|
|
||||||
|
stage.setWidth(windowWidth);
|
||||||
|
stage.setHeight(windowHeight);
|
||||||
|
stage.setX((screenWidth - windowWidth) / 2);
|
||||||
|
stage.setY((screenHeight - windowHeight) / 2);
|
||||||
|
|
||||||
|
stage.show();
|
||||||
|
|
||||||
|
stage.setOnCloseRequest(event -> {
|
||||||
|
WebServer.stop();
|
||||||
|
System.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.web;
|
||||||
|
|
||||||
|
import io.javalin.Javalin;
|
||||||
|
import io.javalin.http.staticfiles.Location;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
|
||||||
|
import java.awt.Desktop;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class WebServer {
|
||||||
|
|
||||||
|
private static final LauncherAPI api = new LauncherAPI();
|
||||||
|
private static Javalin app;
|
||||||
|
private static int currentPort;
|
||||||
|
private static volatile boolean running = false;
|
||||||
|
|
||||||
|
public static int findFreePort(int startPort) throws IOException {
|
||||||
|
for (int port = startPort; port < startPort + 100; port++) {
|
||||||
|
if (isPortAvailable(port)) {
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IOException("Не удалось найти свободный порт в диапазоне " + startPort + "-" + (startPort + 99));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPortAvailable(int port) {
|
||||||
|
try (ServerSocket socket = new ServerSocket(port)) {
|
||||||
|
return true;
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void start(int port) throws Exception {
|
||||||
|
currentPort = port;
|
||||||
|
running = true;
|
||||||
|
|
||||||
|
// Отключаем логирование Javalin в консоль
|
||||||
|
System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "error");
|
||||||
|
|
||||||
|
app = Javalin.create(config -> {
|
||||||
|
config.staticFiles.add("/webapp", Location.CLASSPATH);
|
||||||
|
config.staticFiles.add("/assets", Location.CLASSPATH);
|
||||||
|
}).start(port);
|
||||||
|
|
||||||
|
// API эндпоинты
|
||||||
|
setupApiRoutes();
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.brightGreen("✓ Web UI готов на http://localhost:" + port));
|
||||||
|
|
||||||
|
// Блокируем главный поток (сервер работает)
|
||||||
|
while (running) {
|
||||||
|
Thread.sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setupApiRoutes() {
|
||||||
|
// Auth
|
||||||
|
app.get("/api/auth/status", ctx -> {
|
||||||
|
if (AuthManager.loadSavedSession()) {
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"loggedIn", true,
|
||||||
|
"username", AuthManager.getUsername()
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"loggedIn", false
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/login", ctx -> {
|
||||||
|
Map<String, String> body = ctx.bodyAsClass(Map.class);
|
||||||
|
String username = body.get("username");
|
||||||
|
String password = body.get("password");
|
||||||
|
|
||||||
|
if (username == null || password == null) {
|
||||||
|
ctx.status(400).json(Map.of("success", false, "error", "Missing username or password"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = api.login(username, password);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of("success", true, "username", username));
|
||||||
|
} else {
|
||||||
|
ctx.status(401).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/auth/logout", ctx -> {
|
||||||
|
AuthManager.logout();
|
||||||
|
ctx.json(Map.of("success", true));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instances - локальные
|
||||||
|
app.get("/api/instances", ctx -> {
|
||||||
|
var result = api.getAllInstances();
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of("success", true, "data", result.getData()));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instance детали
|
||||||
|
app.get("/api/instances/{name}", ctx -> {
|
||||||
|
String name = ctx.pathParam("name");
|
||||||
|
var result = api.instances().getInstance(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of("success", true, "data", result.getData()));
|
||||||
|
} else {
|
||||||
|
ctx.status(404).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Launch
|
||||||
|
app.post("/api/instances/{name}/launch", ctx -> {
|
||||||
|
String name = ctx.pathParam("name");
|
||||||
|
var result = api.launch(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of("success", true, "message", "Launch started"));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
app.post("/api/instances/{name}/delete", ctx -> {
|
||||||
|
String name = ctx.pathParam("name");
|
||||||
|
var result = api.instances().deleteInstance(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of("success", true, "message", "Instance deleted"));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ZernMC серверные сборки
|
||||||
|
app.get("/api/instances/zernmc", ctx -> {
|
||||||
|
// TODO: получить реальные сборки с сервера
|
||||||
|
List<Map<String, Object>> packs = List.of(
|
||||||
|
Map.of("name", "ZernMC SkyBlock", "version", 1, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 150),
|
||||||
|
Map.of("name", "ZernMC RPG", "version", 3, "loader", "Fabric", "loaderVersion", "0.15.9", "filesCount", 200)
|
||||||
|
);
|
||||||
|
ctx.json(Map.of("success", true, "data", packs));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Установка ZernMC сборки
|
||||||
|
app.post("/api/instances/zernmc/install", ctx -> {
|
||||||
|
Map<String, String> body = ctx.bodyAsClass(Map.class);
|
||||||
|
String packName = body.get("packName");
|
||||||
|
String instanceName = body.get("instanceName");
|
||||||
|
|
||||||
|
if (packName == null || instanceName == null) {
|
||||||
|
ctx.status(400).json(Map.of("success", false, "error", "Missing packName or instanceName"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = api.install().installZernMCPack(packName, instanceName);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", Map.of(
|
||||||
|
"name", result.getData().getName(),
|
||||||
|
"mcVersion", result.getData().getMcVersion(),
|
||||||
|
"loaderType", result.getData().getLoaderType(),
|
||||||
|
"serverVersion", result.getData().getServerVersion()
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка обновлений
|
||||||
|
app.get("/api/instances/{name}/updates", ctx -> {
|
||||||
|
String name = ctx.pathParam("name");
|
||||||
|
var result = api.install().checkForUpdates(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", Map.of(
|
||||||
|
"hasUpdate", result.getData().isHasUpdate(),
|
||||||
|
"isServerPack", result.getData().isServerPack(),
|
||||||
|
"currentVersion", result.getData().getCurrentVersion(),
|
||||||
|
"latestVersion", result.getData().getLatestVersion()
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка хешей
|
||||||
|
app.get("/api/instances/{name}/verify", ctx -> {
|
||||||
|
String name = ctx.pathParam("name");
|
||||||
|
var result = api.install().verifyHashes(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", Map.of(
|
||||||
|
"hasMismatches", result.getData().hasMismatches(),
|
||||||
|
"mismatchedFiles", result.getData().getMismatchedFiles()
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получение времени игры
|
||||||
|
app.get("/api/instances/{name}/playtime", ctx -> {
|
||||||
|
String name = ctx.pathParam("name");
|
||||||
|
var result = api.install().getPlayTime(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
ctx.json(Map.of(
|
||||||
|
"success", true,
|
||||||
|
"data", Map.of(
|
||||||
|
"totalMinutes", result.getData().getTotalMinutes(),
|
||||||
|
"fromServer", result.getData().isFromServer(),
|
||||||
|
"formatted", result.getData().getFormattedTime()
|
||||||
|
)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
ctx.status(500).json(Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Minecraft версии
|
||||||
|
app.get("/api/versions", ctx -> {
|
||||||
|
List<String> versions = List.of(
|
||||||
|
"1.21.4", "1.21.3", "1.21.2", "1.21.1", "1.21",
|
||||||
|
"1.20.4", "1.20.3", "1.20.2", "1.20.1", "1.20",
|
||||||
|
"1.19.4", "1.19.3", "1.19.2", "1.19.1", "1.19",
|
||||||
|
"1.18.2", "1.18.1", "1.18",
|
||||||
|
"1.17.1", "1.17"
|
||||||
|
);
|
||||||
|
ctx.json(Map.of("success", true, "data",
|
||||||
|
versions.stream().map(v -> Map.of("id", v)).toList()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Версии лоадеров для конкретной версии Minecraft
|
||||||
|
app.get("/api/versions/{version}/loaders/{loader}", ctx -> {
|
||||||
|
String version = ctx.pathParam("version");
|
||||||
|
String loader = ctx.pathParam("loader");
|
||||||
|
|
||||||
|
List<Map<String, String>> loaderVersions = switch (loader.toLowerCase()) {
|
||||||
|
case "fabric" -> List.of(
|
||||||
|
Map.of("version", "0.16.9"),
|
||||||
|
Map.of("version", "0.16.8"),
|
||||||
|
Map.of("version", "0.16.7"),
|
||||||
|
Map.of("version", "0.16.6"),
|
||||||
|
Map.of("version", "0.16.5"),
|
||||||
|
Map.of("version", "0.15.11"),
|
||||||
|
Map.of("version", "0.15.10"),
|
||||||
|
Map.of("version", "0.15.9")
|
||||||
|
);
|
||||||
|
case "forge" -> List.of(
|
||||||
|
Map.of("version", "1.21-51.0.0"),
|
||||||
|
Map.of("version", "1.20.4-49.0.0"),
|
||||||
|
Map.of("version", "1.20.1-47.1.0"),
|
||||||
|
Map.of("version", "1.19.2-43.2.0"),
|
||||||
|
Map.of("version", "1.18.2-40.2.0")
|
||||||
|
);
|
||||||
|
case "neoforge" -> List.of(
|
||||||
|
Map.of("version", "21.0.0-beta"),
|
||||||
|
Map.of("version", "1.21-21.0.0"),
|
||||||
|
Map.of("version", "1.20.4-21.0.0"),
|
||||||
|
Map.of("version", "1.20.1-21.0.0")
|
||||||
|
);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.json(Map.of("success", true, "data", loaderVersions));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Установка ванильной сборки
|
||||||
|
app.post("/api/instances/vanilla/install", ctx -> {
|
||||||
|
Map<String, String> body = ctx.bodyAsClass(Map.class);
|
||||||
|
String mcVersion = body.get("mcVersion");
|
||||||
|
String loader = body.get("loader");
|
||||||
|
String loaderVersion = body.get("loaderVersion");
|
||||||
|
String instanceName = body.get("instanceName");
|
||||||
|
|
||||||
|
if (mcVersion == null || instanceName == null) {
|
||||||
|
ctx.status(400).json(Map.of("success", false, "error", "Missing required parameters"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: реализовать установку ванильной сборки
|
||||||
|
String desc = loader != null ? mcVersion + " + " + loader + " " + loaderVersion : mcVersion + " Vanilla";
|
||||||
|
ctx.json(Map.of("success", true, "message", "Vanilla installation started: " + desc));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/api/health", ctx -> {
|
||||||
|
ctx.json(Map.of("success", true, "status", "ok"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void openBrowser(String url) {
|
||||||
|
try {
|
||||||
|
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||||
|
Desktop.getDesktop().browse(new URI(url));
|
||||||
|
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически. Откройте вручную: " + url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void stop() {
|
||||||
|
running = false;
|
||||||
|
if (app != null) {
|
||||||
|
app.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,768 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #12121a;
|
||||||
|
--bg-card: #1a1a24;
|
||||||
|
--bg-card-hover: #222230;
|
||||||
|
--bg-sidebar: #0d0d12;
|
||||||
|
--accent-primary: #e94560;
|
||||||
|
--accent-secondary: #ff6b6b;
|
||||||
|
--accent-glow: rgba(233, 69, 96, 0.3);
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0b0;
|
||||||
|
--text-muted: #606070;
|
||||||
|
--border-color: #2a2a3a;
|
||||||
|
--success: #4ade80;
|
||||||
|
--error: #f87171;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-glow: 0 0 30px var(--accent-glow);
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--transition-fast: 150ms ease;
|
||||||
|
--transition-normal: 300ms ease;
|
||||||
|
--transition-slow: 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#grid-canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn var(--transition-slow) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== LOGIN SCREEN ==================== */
|
||||||
|
.login-container {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 48px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
animation: slideUp var(--transition-slow) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--text-primary), var(--accent-primary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-loader {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--error);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shake 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-5px); }
|
||||||
|
75% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== MAIN LAYOUT ==================== */
|
||||||
|
.main-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr 200px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1600px;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn var(--transition-slow) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
border-right: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-small svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-version {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-instance-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-instance {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 16px;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-instance:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card-mini {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-version {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
background: rgba(233, 69, 96, 0.15);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username-display {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-logout:hover {
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border-color: var(--error);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content - Logs */
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-header h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear-logs {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-clear-logs:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
padding: 4px 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
animation: fadeIn var(--transition-fast) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Panel - Play Button */
|
||||||
|
.right-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px 30px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: var(--transition-normal);
|
||||||
|
box-shadow: 0 4px 20px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 8px 40px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== MODAL ==================== */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(10, 10, 15, 0.9);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn var(--transition-fast) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: slideUp var(--transition-normal) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 16px 24px;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover:not(.active) {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
padding: 24px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input, .text-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input:focus, .text-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-install {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-install:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-progress {
|
||||||
|
padding: 24px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width var(--transition-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== LOADING ==================== */
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(10, 10, 15, 0.9);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fadeIn var(--transition-fast) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 3px solid var(--border-color);
|
||||||
|
border-top-color: var(--accent-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== ANIMATIONS ==================== */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardFadeIn {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== RESPONSIVE ==================== */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.main-layout {
|
||||||
|
grid-template-columns: 240px 1fr 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
padding: 16px;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== SCROLLBAR ==================== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ZernMC Launcher</title>
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="grid-canvas"></canvas>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="login-screen" class="screen hidden">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="logo-section">
|
||||||
|
<div class="logo-placeholder">
|
||||||
|
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
|
||||||
|
<rect width="80" height="80" rx="20" fill="#e94560"/>
|
||||||
|
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="app-title">ZernMC Launcher</h1>
|
||||||
|
<p class="app-version">v<span id="version">1.0.8</span></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="login-form" class="login-form">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">
|
||||||
|
<span class="btn-text">Войти</span>
|
||||||
|
<div class="btn-loader hidden"></div>
|
||||||
|
</button>
|
||||||
|
<p id="login-error" class="error-message hidden"></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Screen -->
|
||||||
|
<div id="main-screen" class="screen hidden">
|
||||||
|
<div class="main-layout">
|
||||||
|
<!-- Left Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo-small">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
|
||||||
|
<rect width="40" height="40" rx="10" fill="#e94560"/>
|
||||||
|
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="header-info">
|
||||||
|
<h1 class="header-title">ZernMC</h1>
|
||||||
|
<span class="header-version">v<span id="header-version">1.0.8</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<!-- Current Instance -->
|
||||||
|
<div class="current-instance-section">
|
||||||
|
<h3 class="section-label">Текущая сборка</h3>
|
||||||
|
<div id="current-instance" class="current-instance">
|
||||||
|
<div class="instance-card-mini">
|
||||||
|
<span class="instance-name">Загрузка...</span>
|
||||||
|
<span class="instance-version">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Button -->
|
||||||
|
<button id="download-btn" class="btn-download">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="7 10 12 15 17 10"/>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Скачать сборку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span class="username-display" id="username-display"></span>
|
||||||
|
<button class="btn-logout" id="logout-btn" title="Выйти">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||||
|
<polyline points="16 17 21 12 16 7"/>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="logs-section">
|
||||||
|
<div class="logs-header">
|
||||||
|
<h2>Логи</h2>
|
||||||
|
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<div id="logs-container" class="logs-container">
|
||||||
|
<div class="log-entry info">Ожидание запуска...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Right Panel - Play Button -->
|
||||||
|
<div class="right-panel">
|
||||||
|
<button id="play-btn" class="btn-play">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||||
|
</svg>
|
||||||
|
ИГРАТЬ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download Modal -->
|
||||||
|
<div id="download-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Скачать сборку</h2>
|
||||||
|
<button class="modal-close" id="close-download-modal">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
|
||||||
|
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ZernMC Tab -->
|
||||||
|
<div id="tab-zernmc" class="tab-content active">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Выберите сборку</label>
|
||||||
|
<select id="zernmc-pack-select" class="select-input">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название сборки (системное)</label>
|
||||||
|
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
|
||||||
|
</div>
|
||||||
|
<button id="install-zernmc-btn" class="btn-install">
|
||||||
|
Скачать и установить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vanilla Tab -->
|
||||||
|
<div id="tab-vanilla" class="tab-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Версия Minecraft</label>
|
||||||
|
<select id="mc-version-select" class="select-input">
|
||||||
|
<option value="">Выберите версию</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Лоадер</label>
|
||||||
|
<select id="loader-select" class="select-input">
|
||||||
|
<option value="vanilla">Vanilla (без лоадера)</option>
|
||||||
|
<option value="fabric">Fabric</option>
|
||||||
|
<option value="forge">Forge</option>
|
||||||
|
<option value="neoforge">NeoForge</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="loader-version-group" class="form-group hidden">
|
||||||
|
<label>Версия лоадера</label>
|
||||||
|
<select id="loader-version-select" class="select-input">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название сборки</label>
|
||||||
|
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
|
||||||
|
</div>
|
||||||
|
<button id="install-vanilla-btn" class="btn-install">
|
||||||
|
Скачать и установить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="download-progress" class="download-progress hidden">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<p class="progress-text" id="progress-text">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loading-overlay" class="loading-overlay hidden">
|
||||||
|
<div class="loader"></div>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
this.state = 'INIT';
|
||||||
|
this.username = null;
|
||||||
|
this.currentInstance = null;
|
||||||
|
this.instances = [];
|
||||||
|
this.zernmcPacks = [];
|
||||||
|
this.mcVersions = [];
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.bindEvents();
|
||||||
|
this.initGridAnimation();
|
||||||
|
await this.checkAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Login form
|
||||||
|
document.getElementById('login-form').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout button
|
||||||
|
document.getElementById('logout-btn').addEventListener('click', () => {
|
||||||
|
this.handleLogout();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download button
|
||||||
|
document.getElementById('download-btn').addEventListener('click', () => {
|
||||||
|
this.showDownloadModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
document.getElementById('close-download-modal').addEventListener('click', () => {
|
||||||
|
this.hideDownloadModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal tabs
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
this.switchTab(e.target.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play button
|
||||||
|
document.getElementById('play-btn').addEventListener('click', () => {
|
||||||
|
this.launchInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear logs
|
||||||
|
document.getElementById('clear-logs').addEventListener('click', () => {
|
||||||
|
this.clearLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loader selection
|
||||||
|
document.getElementById('loader-select').addEventListener('change', (e) => {
|
||||||
|
this.onLoaderChange(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Install buttons
|
||||||
|
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
|
||||||
|
this.installZernMCPack();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
|
||||||
|
this.installVanilla();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== GRID ANIMATION ====================
|
||||||
|
initGridAnimation() {
|
||||||
|
const canvas = document.getElementById('grid-canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
let mouseX = 0, mouseY = 0;
|
||||||
|
let offsetX = 0, offsetY = 0;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', (e) => {
|
||||||
|
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||||
|
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
|
||||||
|
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawGrid(ctx, width, height, offsetX, offsetY) {
|
||||||
|
const gridSize = 50;
|
||||||
|
const dotSize = 1;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#e94560';
|
||||||
|
|
||||||
|
for (let x = 0; x <= width; x += gridSize) {
|
||||||
|
for (let y = 0; y <= height; y += gridSize) {
|
||||||
|
const px = x + offsetX * 10;
|
||||||
|
const py = y + offsetY * 10;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API ====================
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AUTH ====================
|
||||||
|
async checkAuth() {
|
||||||
|
this.showLoading(true);
|
||||||
|
const result = await this.request('/auth/status');
|
||||||
|
|
||||||
|
if (result.loggedIn) {
|
||||||
|
this.username = result.username;
|
||||||
|
this.showMainScreen();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
} else {
|
||||||
|
this.showLoginScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLogin() {
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
const btn = document.querySelector('#login-form button[type="submit"]');
|
||||||
|
const btnText = btn.querySelector('.btn-text');
|
||||||
|
const btnLoader = btn.querySelector('.btn-loader');
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
this.showError('Введите имя пользователя и пароль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btnText.classList.add('hidden');
|
||||||
|
btnLoader.classList.remove('hidden');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
const result = await this.request('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btnText.classList.remove('hidden');
|
||||||
|
btnLoader.classList.add('hidden');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.username = result.username;
|
||||||
|
this.showMainScreen();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
} else {
|
||||||
|
this.showError(result.error || 'Ошибка входа');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleLogout() {
|
||||||
|
await this.request('/auth/logout', { method: 'POST' });
|
||||||
|
this.username = null;
|
||||||
|
this.currentInstance = null;
|
||||||
|
this.showLoginScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INSTANCES ====================
|
||||||
|
async loadCurrentInstance() {
|
||||||
|
const result = await this.request('/instances');
|
||||||
|
|
||||||
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
|
this.currentInstance = result.data[0];
|
||||||
|
this.renderCurrentInstance(this.currentInstance);
|
||||||
|
this.enablePlayButton(true);
|
||||||
|
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
|
||||||
|
} else {
|
||||||
|
this.renderNoInstance();
|
||||||
|
this.enablePlayButton(false);
|
||||||
|
this.addLog('Установите сборку для игры', 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCurrentInstance(instance) {
|
||||||
|
const container = document.getElementById('current-instance');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="instance-card-mini">
|
||||||
|
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
|
||||||
|
<span class="instance-version">${this.escapeHtml(instance.version || 'Vanilla')}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoInstance() {
|
||||||
|
const container = document.getElementById('current-instance');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="instance-card-mini">
|
||||||
|
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
|
||||||
|
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
enablePlayButton(enabled) {
|
||||||
|
const btn = document.getElementById('play-btn');
|
||||||
|
btn.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async launchInstance() {
|
||||||
|
if (!this.currentInstance) return;
|
||||||
|
|
||||||
|
this.addLog('Проверка целостности файлов...', 'info');
|
||||||
|
this.enablePlayButton(false);
|
||||||
|
|
||||||
|
const result = await this.request(`/instances/${this.currentInstance.name}/launch`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.addLog('Сборка запущена!', 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка: ' + result.error, 'error');
|
||||||
|
this.enablePlayButton(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DOWNLOAD MODAL ====================
|
||||||
|
async showDownloadModal() {
|
||||||
|
document.getElementById('download-modal').classList.remove('hidden');
|
||||||
|
await this.loadZernMCPacks();
|
||||||
|
await this.loadMCVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDownloadModal() {
|
||||||
|
document.getElementById('download-modal').classList.add('hidden');
|
||||||
|
this.hideProgress();
|
||||||
|
}
|
||||||
|
|
||||||
|
switchTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.toggle('active', content.id === 'tab-' + tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadZernMCPacks() {
|
||||||
|
const select = document.getElementById('zernmc-pack-select');
|
||||||
|
select.innerHTML = '<option value="">Загрузка...</option>';
|
||||||
|
|
||||||
|
const result = await this.request('/instances/zernmc');
|
||||||
|
|
||||||
|
if (result.success && result.data && result.data.length > 0) {
|
||||||
|
this.zernmcPacks = result.data;
|
||||||
|
select.innerHTML = result.data.map(pack =>
|
||||||
|
`<option value="${this.escapeHtml(pack.name)}">${this.escapeHtml(pack.name)} (v${pack.version})</option>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
select.innerHTML = '<option value="">Нет доступных сборок</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMCVersions() {
|
||||||
|
const select = document.getElementById('mc-version-select');
|
||||||
|
select.innerHTML = '<option value="">Загрузка...</option>';
|
||||||
|
|
||||||
|
const result = await this.request('/versions');
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
this.mcVersions = result.data;
|
||||||
|
select.innerHTML = '<option value="">Выберите версию</option>' +
|
||||||
|
result.data.map(v => `<option value="${v.id}">${v.id}</option>`).join('');
|
||||||
|
} else {
|
||||||
|
select.innerHTML = '<option value="">Не удалось загрузить</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLoaderChange(loader) {
|
||||||
|
const loaderVersionGroup = document.getElementById('loader-version-group');
|
||||||
|
const loaderVersionSelect = document.getElementById('loader-version-select');
|
||||||
|
|
||||||
|
if (loader === 'vanilla') {
|
||||||
|
loaderVersionGroup.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
loaderVersionGroup.classList.remove('hidden');
|
||||||
|
loaderVersionSelect.innerHTML = '<option value="">Загрузка...</option>';
|
||||||
|
|
||||||
|
const result = await this.request(`/versions/${document.getElementById('mc-version-select').value}/loaders/${loader}`);
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
loaderVersionSelect.innerHTML = result.data.map(v =>
|
||||||
|
`<option value="${v.version}">${v.version}</option>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
loaderVersionSelect.innerHTML = '<option value="">Нет версий</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installZernMCPack() {
|
||||||
|
const packName = document.getElementById('zernmc-pack-select').value;
|
||||||
|
const instanceName = document.getElementById('zernmc-instance-name').value;
|
||||||
|
|
||||||
|
if (!packName) {
|
||||||
|
alert('Выберите сборку');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceName) {
|
||||||
|
alert('Введите название сборки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showProgress('Установка ZernMC сборки...');
|
||||||
|
this.addLog('Начало установки: ' + packName, 'info');
|
||||||
|
|
||||||
|
const result = await this.request('/instances/zernmc/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ packName, instanceName })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.hideDownloadModal();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
this.addLog('Сборка установлена!', 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка установки: ' + result.error, 'error');
|
||||||
|
this.hideProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installVanilla() {
|
||||||
|
const mcVersion = document.getElementById('mc-version-select').value;
|
||||||
|
const loader = document.getElementById('loader-select').value;
|
||||||
|
const loaderVersion = document.getElementById('loader-version-select').value;
|
||||||
|
const instanceName = document.getElementById('vanilla-instance-name').value;
|
||||||
|
|
||||||
|
if (!mcVersion) {
|
||||||
|
alert('Выберите версию Minecraft');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceName) {
|
||||||
|
alert('Введите название сборки');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loader !== 'vanilla' && !loaderVersion) {
|
||||||
|
alert('Выберите версию лоадера');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showProgress('Установка сборки...');
|
||||||
|
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
|
||||||
|
|
||||||
|
const result = await this.request('/instances/vanilla/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
mcVersion,
|
||||||
|
loader: loader === 'vanilla' ? null : loader,
|
||||||
|
loaderVersion: loader === 'vanilla' ? null : loaderVersion,
|
||||||
|
instanceName
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.hideDownloadModal();
|
||||||
|
await this.loadCurrentInstance();
|
||||||
|
this.addLog('Сборка установлена!', 'success');
|
||||||
|
} else {
|
||||||
|
this.addLog('Ошибка установки: ' + result.error, 'error');
|
||||||
|
this.hideProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showProgress(text) {
|
||||||
|
const progress = document.getElementById('download-progress');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const progressFill = document.getElementById('progress-fill');
|
||||||
|
|
||||||
|
progress.classList.remove('hidden');
|
||||||
|
progressText.textContent = text;
|
||||||
|
progressFill.style.width = '50%';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideProgress() {
|
||||||
|
document.getElementById('download-progress').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LOGS ====================
|
||||||
|
addLog(message, type = 'info') {
|
||||||
|
const container = document.getElementById('logs-container');
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry ${type}`;
|
||||||
|
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||||
|
container.appendChild(entry);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLogs() {
|
||||||
|
const container = document.getElementById('logs-container');
|
||||||
|
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UI HELPERS ====================
|
||||||
|
showLoginScreen() {
|
||||||
|
document.getElementById('login-screen').classList.remove('hidden');
|
||||||
|
document.getElementById('main-screen').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMainScreen() {
|
||||||
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
|
document.getElementById('main-screen').classList.remove('hidden');
|
||||||
|
document.getElementById('username-display').textContent = this.username || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(show) {
|
||||||
|
const overlay = document.getElementById('loading-overlay');
|
||||||
|
if (show) {
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
overlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.instance.InstanceService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class InstanceServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void instanceService_instantiates() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
assertNotNull(service, "InstanceService должен создаваться");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllInstances_returnsResponse() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
ApiResponse<?> response = service.getAllInstances();
|
||||||
|
|
||||||
|
assertNotNull(response, "Ответ не должен быть null");
|
||||||
|
assertTrue(response.isSuccess() || !response.isSuccess(), "Должен быть валидный ответ");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAllInstances_returnsList() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
ApiResponse<?> response = service.getAllInstances();
|
||||||
|
|
||||||
|
assertNotNull(response.getData(), "Data не должен быть null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isInstanceExists_returnsBoolean() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
ApiResponse<Boolean> response = service.isInstanceExists("nonexistent");
|
||||||
|
|
||||||
|
assertNotNull(response, "Ответ не должен быть null");
|
||||||
|
assertTrue(response.isSuccess(), "Проверка должна быть успешной");
|
||||||
|
assertNotNull(response.getData(), "Data должен быть boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isInstanceExists_nonexistentReturnsFalse() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
ApiResponse<Boolean> response = service.isInstanceExists("definitely_nonexistent_12345");
|
||||||
|
|
||||||
|
assertTrue(response.isSuccess());
|
||||||
|
assertFalse(response.getData(), "Несуществующая сборка должна вернуть false");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteInstance_invalidName_returnsError() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
ApiResponse<Boolean> response = service.deleteInstance("nonexistent");
|
||||||
|
|
||||||
|
assertNotNull(response, "Ответ не должен быть null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getInstance_nonexistent_returnsError() {
|
||||||
|
InstanceService service = new InstanceService();
|
||||||
|
ApiResponse<?> response = service.getInstance("definitely_nonexistent_12345");
|
||||||
|
|
||||||
|
assertNotNull(response, "Ответ не должен быть null");
|
||||||
|
assertFalse(response.isSuccess(), "Несуществующая сборка должна вернуть ошибку");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,33 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.web;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.awt.GraphicsEnvironment;
|
||||||
|
|
||||||
|
class HeadlessDetectionTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headlessDetection_works() {
|
||||||
|
boolean isHeadless = GraphicsEnvironment.isHeadless();
|
||||||
|
assertNotNull(isHeadless, "isHeadless() должен возвращать boolean");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void headlessDetection_consistentResult() {
|
||||||
|
boolean isHeadless1 = GraphicsEnvironment.isHeadless();
|
||||||
|
boolean isHeadless2 = GraphicsEnvironment.isHeadless();
|
||||||
|
assertEquals(isHeadless1, isHeadless2, "Результат должен быть консистентным");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void javaFxCheck_works() {
|
||||||
|
try {
|
||||||
|
boolean isHeadless = java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment()
|
||||||
|
.getDefaultScreenDevice() != null;
|
||||||
|
assertFalse(isHeadless, "На Linux без дисплея должно быть headless");
|
||||||
|
} catch (Exception e) {
|
||||||
|
assertTrue(true, "Ожидаемая ошибка на headless");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.web;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
|
||||||
|
class WebServerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFreePort_returnsValidPort() throws IOException {
|
||||||
|
int port = WebServer.findFreePort(8080);
|
||||||
|
assertTrue(port >= 8080, "Порт должен быть >= 8080");
|
||||||
|
assertTrue(port < 8180, "Порт должен быть < 8180");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFreePort_findsDifferentPorts() throws IOException {
|
||||||
|
int port1 = WebServer.findFreePort(9000);
|
||||||
|
int port2 = WebServer.findFreePort(9100);
|
||||||
|
|
||||||
|
assertNotEquals(port1, port2, "Должены быть разные порты");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findFreePort_respectsStartPort() throws IOException {
|
||||||
|
int port = WebServer.findFreePort(9500);
|
||||||
|
assertTrue(port >= 9500, "Порт должен быть >= указанного startPort");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void portRangeTest() throws IOException {
|
||||||
|
int port = WebServer.findFreePort(8080);
|
||||||
|
assertTrue(port >= 8080 && port < 8180, "Порт в допустимом диапазоне 8080-8179");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
import re
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
import json
|
import json
|
||||||
import structlog
|
import structlog
|
||||||
from cachetools import TTLCache
|
from cachetools import TTLCache
|
||||||
import logging
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||||
from datetime import datetime
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||||
|
|
||||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||||
@@ -15,11 +18,9 @@ from middleware import LoggingMiddleware
|
|||||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||||
from log_manager import init_logging
|
from log_manager import init_logging
|
||||||
|
|
||||||
import httpx
|
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||||
import base64
|
from roles import Permissions, has_permission
|
||||||
from fastapi.responses import StreamingResponse
|
from admin_router import router as admin_router
|
||||||
|
|
||||||
from auth import router as auth_router, init_db, verify_jwt
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -35,7 +36,6 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
init_logging()
|
init_logging()
|
||||||
#logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Determine environment
|
# Determine environment
|
||||||
if args.test:
|
if args.test:
|
||||||
@@ -53,8 +53,6 @@ async def lifespan(app: FastAPI):
|
|||||||
DATA_DIR.mkdir(exist_ok=True)
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
app.include_router(auth_router)
|
|
||||||
|
|
||||||
if args.test:
|
if args.test:
|
||||||
await run_test_mode()
|
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.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
|
||||||
|
|
||||||
logger.info("All packs ready. Server is running.")
|
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
|
yield
|
||||||
|
|
||||||
|
# Cleanup proxy client
|
||||||
|
if proxy_client:
|
||||||
|
await proxy_client.aclose()
|
||||||
|
|
||||||
logger.info("Server shutting down...")
|
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
|
# Create app with lifespan
|
||||||
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||||
|
|
||||||
# Add logging middleware
|
# Add Logging middleware
|
||||||
app.add_middleware(LoggingMiddleware)
|
app.add_middleware(LoggingMiddleware)
|
||||||
|
|
||||||
|
# Register routers
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
|
||||||
# Monkey patch to catch invalid HTTP requests
|
# Monkey patch to catch invalid HTTP requests
|
||||||
original_data_received = HttpToolsProtocol.data_received
|
original_data_received = HttpToolsProtocol.data_received
|
||||||
@@ -95,9 +432,7 @@ def patched_data_received(self, data):
|
|||||||
return original_data_received(self, data)
|
return original_data_received(self, data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
client = self.transport.get_extra_info('peername')
|
client = self.transport.get_extra_info('peername')
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Показываем первые 200 байт запроса в HEX для диагностики
|
|
||||||
hex_preview = data[:100].hex() if len(data) > 0 else "empty"
|
hex_preview = data[:100].hex() if len(data) > 0 else "empty"
|
||||||
|
|
||||||
logger.error(f"Invalid HTTP request from {client}")
|
logger.error(f"Invalid HTTP request from {client}")
|
||||||
@@ -107,16 +442,14 @@ def patched_data_received(self, data):
|
|||||||
try:
|
try:
|
||||||
raw_data = data[:500].decode('utf-8', errors='replace')
|
raw_data = data[:500].decode('utf-8', errors='replace')
|
||||||
logger.error(f"Raw request data: {repr(raw_data)}")
|
logger.error(f"Raw request data: {repr(raw_data)}")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Не перевыбрасываем исключение, а возвращаем 400 ответ
|
|
||||||
# Это важно! Иначе клиент не получит ответ
|
|
||||||
try:
|
try:
|
||||||
response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request"
|
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.write(response)
|
||||||
self.transport.close()
|
self.transport.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -128,7 +461,6 @@ HttpToolsProtocol.data_received = patched_data_received
|
|||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
"""Root endpoint"""
|
"""Root endpoint"""
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.info("Root endpoint accessed")
|
logger.info("Root endpoint accessed")
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -144,12 +476,30 @@ async def health():
|
|||||||
return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()}
|
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")
|
@app.get("/packs")
|
||||||
async def list_packs():
|
async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||||
"""List all available packs"""
|
"""List all available packs - требует проходку для просмотра"""
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
# Проверяем, есть ли право на просмотр сборок
|
||||||
|
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Для просмотра сборок требуется активная проходка"
|
||||||
|
)
|
||||||
|
|
||||||
packs = []
|
packs = []
|
||||||
|
|
||||||
for pack_dir in PACKS_DIR.iterdir():
|
for pack_dir in PACKS_DIR.iterdir():
|
||||||
@@ -159,7 +509,6 @@ async def list_packs():
|
|||||||
try:
|
try:
|
||||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
meta = json.load(f)
|
meta = json.load(f)
|
||||||
# Исправлено: конвертируем updated_at в строку если это datetime
|
|
||||||
updated_at = meta.get("updated_at")
|
updated_at = meta.get("updated_at")
|
||||||
if updated_at and isinstance(updated_at, datetime):
|
if updated_at and isinstance(updated_at, datetime):
|
||||||
updated_at = updated_at.isoformat()
|
updated_at = updated_at.isoformat()
|
||||||
@@ -171,7 +520,8 @@ async def list_packs():
|
|||||||
"updated_at": updated_at,
|
"updated_at": updated_at,
|
||||||
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
"minecraft_version": meta.get("minecraft_version", "unknown"),
|
||||||
"loader_type": meta.get("loader_type", "vanilla"),
|
"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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load pack meta for {pack_dir.name}: {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")
|
@app.post("/pack/{pack_name}/diff")
|
||||||
async def get_pack_diff(pack_name: str, request: Request):
|
async def get_pack_diff(
|
||||||
"""
|
pack_name: str,
|
||||||
Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user) # Добавляем зависимость
|
||||||
|
):
|
||||||
|
"""Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||||
Server returns diff information
|
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"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
# Читаем тело запроса
|
# Читаем тело запроса
|
||||||
@@ -491,17 +852,12 @@ async def get_launcher_full_info():
|
|||||||
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
|
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
|
||||||
# скачивать файлы через сервер Zern
|
# скачивать файлы через сервер Zern
|
||||||
|
|
||||||
# Создаем HTTP клиент для прокси
|
# HTTP клиент для прокси — создаётся в lifespan, закрывается при shutdown
|
||||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
proxy_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
# Кэш для часто запрашиваемых данных (5 минут)
|
# Кэш для часто запрашиваемых данных (5 минут)
|
||||||
from cachetools import TTLCache
|
|
||||||
proxy_cache = TTLCache(maxsize=50, ttl=300)
|
proxy_cache = TTLCache(maxsize=50, ttl=300)
|
||||||
|
|
||||||
# Список заблокированных/проблемных хостов (можно обновлять)
|
|
||||||
BLOCKED_HOSTS = []
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/proxy/fabric/versions/loader")
|
@app.get("/proxy/fabric/versions/loader")
|
||||||
async def proxy_fabric_versions(request: Request):
|
async def proxy_fabric_versions(request: Request):
|
||||||
"""Прокси для Fabric Meta API - список версий загрузчика"""
|
"""Прокси для Fabric Meta API - список версий загрузчика"""
|
||||||
@@ -548,7 +904,6 @@ async def proxy_fabric_installer_latest(request: Request):
|
|||||||
xml = response.text
|
xml = response.text
|
||||||
|
|
||||||
# Парсим последнюю версию из XML
|
# Парсим последнюю версию из XML
|
||||||
import re
|
|
||||||
match = re.search(r'<latest>([^<]+)</latest>', xml)
|
match = re.search(r'<latest>([^<]+)</latest>', xml)
|
||||||
if match:
|
if match:
|
||||||
version = match.group(1)
|
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)}")
|
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")
|
@app.get("/proxy/download")
|
||||||
async def proxy_download(request: Request):
|
async def proxy_download(request: Request):
|
||||||
"""Универсальный прокси для скачивания файлов"""
|
"""Универсальный прокси для скачивания файлов"""
|
||||||
@@ -742,11 +1149,11 @@ async def proxy_download(request: Request):
|
|||||||
"launchermeta.mojang.com",
|
"launchermeta.mojang.com",
|
||||||
"resources.download.minecraft.net",
|
"resources.download.minecraft.net",
|
||||||
"maven.minecraftforge.net",
|
"maven.minecraftforge.net",
|
||||||
"files.minecraftforge.net"
|
"files.minecraftforge.net",
|
||||||
|
"maven.neoforged.net"
|
||||||
]
|
]
|
||||||
|
|
||||||
# Проверяем, что URL ведет на разрешенный домен
|
# Проверяем, что URL ведет на разрешенный домен
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
domain = parsed.netloc.lower()
|
domain = parsed.netloc.lower()
|
||||||
|
|
||||||
@@ -819,7 +1226,8 @@ async def proxy_status():
|
|||||||
"piston-meta.mojang.com",
|
"piston-meta.mojang.com",
|
||||||
"launchermeta.mojang.com",
|
"launchermeta.mojang.com",
|
||||||
"resources.download.minecraft.net",
|
"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"
|
"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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+1
-13
@@ -28,16 +28,4 @@ class PackMeta(BaseModel):
|
|||||||
minecraft_version: str
|
minecraft_version: str
|
||||||
loader_type: str
|
loader_type: str
|
||||||
loader_version: Optional[str] = None
|
loader_version: Optional[str] = None
|
||||||
|
asset_index: 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)
|
|
||||||
@@ -109,6 +109,7 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
|
|||||||
minecraft_version = "1.20.4"
|
minecraft_version = "1.20.4"
|
||||||
loader_type = "vanilla"
|
loader_type = "vanilla"
|
||||||
loader_version = None
|
loader_version = None
|
||||||
|
asset_index = None
|
||||||
|
|
||||||
pack_config_path = pack_path / "instance.json"
|
pack_config_path = pack_path / "instance.json"
|
||||||
if pack_config_path.exists():
|
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)
|
minecraft_version = config.get("minecraftVersion", minecraft_version)
|
||||||
loader_type = config.get("loaderType", loader_type)
|
loader_type = config.get("loaderType", loader_type)
|
||||||
loader_version = config.get("loaderVersion")
|
loader_version = config.get("loaderVersion")
|
||||||
|
asset_index = config.get("assetIndex")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load instance.json for {pack_name}: {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,
|
ignored_dirs=ignored_dirs,
|
||||||
minecraft_version=minecraft_version,
|
minecraft_version=minecraft_version,
|
||||||
loader_type=loader_type,
|
loader_type=loader_type,
|
||||||
loader_version=loader_version
|
loader_version=loader_version,
|
||||||
|
asset_index=asset_index
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to disk (синхронно)
|
# 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