Compare commits
65 Commits
main
..
59480217aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 59480217aa | |||
| 4697b16ab4 | |||
| 099df80cc6 | |||
| 74cd5ffdf3 | |||
| 01668dd3bf | |||
| b2dbbac6ca | |||
| e32a057684 | |||
| d4dc35aac3 | |||
| 1e7231af57 | |||
| fd6e292d6e | |||
| 1e876ffe28 | |||
| 2d515108f0 | |||
| 13c9f67f6e | |||
| 659265c2f0 | |||
| d8f189558a | |||
| 6f56012e3a | |||
| 3a0570e7da | |||
| 985abf7440 | |||
| ec551ab2e3 | |||
| e5948b5337 | |||
| 5a826c8511 | |||
| ce12854e1b | |||
| e566703332 | |||
| aaa19df5e4 | |||
| 0ee8077787 | |||
| fba944b4b8 | |||
| d39b40053a | |||
| 1199ca9e21 | |||
| 50080d890f | |||
| f6fbb66cdc | |||
| d7a928cce4 | |||
| 3bd3d1d0e8 | |||
| df9fa7b867 | |||
| 81fbe028e8 | |||
| 513c07666b | |||
| 04f97c3c80 | |||
| f40cf7afed | |||
| 0cef411125 | |||
| 523f659269 | |||
| 04620d76c4 | |||
| 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.
|
||||||
@@ -2,6 +2,8 @@ logs/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
./.venv/
|
./.venv/
|
||||||
launcher/target
|
launcher/target
|
||||||
|
bootstrap/target
|
||||||
|
src/target
|
||||||
server/builds
|
server/builds
|
||||||
server/packs
|
server/packs
|
||||||
server/data
|
server/data
|
||||||
@@ -9,3 +11,4 @@ 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/
|
||||||
@@ -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`.
|
|
||||||
|
|
||||||
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
Если вы случайно кликнули мышкой в окне лаунчера и он «заморозился» — просто нажмите **любую клавишу** на клавиатуре.
|
||||||
|
|
||||||
### Расположение сборок
|
### Расположение сборок
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
server/builds/
|
||||||
|
server/logs/
|
||||||
|
|
||||||
|
# Colab
|
||||||
|
colab/
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>me.sashegdev</groupId>
|
||||||
|
<artifactId>ZernMCLauncher</artifactId>
|
||||||
|
<version>1.0.9</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>zernmc-bootstrap</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>ZernMC Bootstrap</name>
|
||||||
|
<description>Bootstrap module - handles updates and Java launching</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Minimal dependencies for Bootstrap -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
|
<artifactId>httpclient</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outputFile>../../server/builds/zernmc-bootstrap.jar</outputFile>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.util.jar.Attributes;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.util.jar.Manifest;
|
||||||
|
|
||||||
|
public class Bootstrap {
|
||||||
|
private static final String JAR_NAME = "zernmclauncher.jar";
|
||||||
|
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||||
|
|
||||||
|
private static Path baseDir;
|
||||||
|
private static Path binDir;
|
||||||
|
private static Path logDir;
|
||||||
|
|
||||||
|
private static Path getLauncherJar() {
|
||||||
|
return binDir.resolve(JAR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
baseDir = Paths.get("").toAbsolutePath();
|
||||||
|
binDir = baseDir.resolve("bin");
|
||||||
|
Files.createDirectories(binDir);
|
||||||
|
logDir = baseDir.resolve("logs");
|
||||||
|
Files.createDirectories(logDir);
|
||||||
|
|
||||||
|
log("=== ZernMC Launcher ===");
|
||||||
|
|
||||||
|
// Определяем режим запуска
|
||||||
|
List<String> argList = Arrays.asList(args);
|
||||||
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
boolean jfxMode = !cliMode; // по умолчанию JFX
|
||||||
|
|
||||||
|
// Проверка и обновление лаунчера
|
||||||
|
String currentVersion = readCurrentVersion();
|
||||||
|
String serverVersion = getServerVersion();
|
||||||
|
|
||||||
|
log("Локальная версия: " + currentVersion);
|
||||||
|
log("Версия на сервере: " + serverVersion);
|
||||||
|
|
||||||
|
if (isNewer(serverVersion, currentVersion)) {
|
||||||
|
log("Доступно обновление!");
|
||||||
|
downloadUpdate(serverVersion);
|
||||||
|
} else {
|
||||||
|
log("Версия актуальна");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск в выбранном режиме
|
||||||
|
if (jfxMode) {
|
||||||
|
launchJFX();
|
||||||
|
} else {
|
||||||
|
launchCLI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void log(String msg) {
|
||||||
|
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||||
|
System.out.println(entry);
|
||||||
|
try {
|
||||||
|
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readCurrentVersion() {
|
||||||
|
Path jar = getLauncherJar();
|
||||||
|
if (Files.exists(jar)) {
|
||||||
|
try (JarFile jarFile = new JarFile(jar.toFile())) {
|
||||||
|
Manifest manifest = jarFile.getManifest();
|
||||||
|
if (manifest != null) {
|
||||||
|
String v = manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION);
|
||||||
|
if (v != null && !v.isBlank()) return v;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("Ошибка чтения манифеста: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getServerVersion() {
|
||||||
|
try {
|
||||||
|
URL url = new URL(BASE_URL + "/launcher/version");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setConnectTimeout(5000);
|
||||||
|
conn.setReadTimeout(5000);
|
||||||
|
if (conn.getResponseCode() == 200) {
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
sb.append(line);
|
||||||
|
}
|
||||||
|
String response = sb.toString();
|
||||||
|
|
||||||
|
int versionStart = response.indexOf("\"version\":\"");
|
||||||
|
if (versionStart >= 0) {
|
||||||
|
int afterVersion = versionStart + 11;
|
||||||
|
int versionEnd = response.indexOf("\"", afterVersion);
|
||||||
|
if (versionEnd > afterVersion) {
|
||||||
|
return response.substring(afterVersion, versionEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("Ошибка получения версии: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isNewer(String server, String current) {
|
||||||
|
try {
|
||||||
|
String[] sa = server.split("\\.");
|
||||||
|
String[] ca = current.split("\\.");
|
||||||
|
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
|
||||||
|
int sv = Integer.parseInt(sa[i]);
|
||||||
|
int cv = Integer.parseInt(ca[i]);
|
||||||
|
if (sv > cv) return true;
|
||||||
|
if (sv < cv) return false;
|
||||||
|
}
|
||||||
|
return sa.length > ca.length;
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void downloadUpdate(String newVersion) throws Exception {
|
||||||
|
log("Проверка обновлений...");
|
||||||
|
|
||||||
|
// Получаем мета с сервера
|
||||||
|
Map<String, FileMeta> serverFiles = fetchServerMeta(newVersion);
|
||||||
|
if (serverFiles.isEmpty()) {
|
||||||
|
log("Не удалось получить мета с сервера");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сканируем локальные файлы
|
||||||
|
Map<String, String> localFiles = scanLocalFiles();
|
||||||
|
log("Локальных файлов: " + localFiles.size());
|
||||||
|
log("Файлов на сервере: " + serverFiles.size());
|
||||||
|
|
||||||
|
// Сравниваем и скачиваем
|
||||||
|
int downloaded = 0;
|
||||||
|
int skipped = 0;
|
||||||
|
|
||||||
|
for (Map.Entry<String, FileMeta> entry : serverFiles.entrySet()) {
|
||||||
|
String filePath = entry.getKey();
|
||||||
|
FileMeta serverMeta = entry.getValue();
|
||||||
|
|
||||||
|
String localHash = localFiles.get(filePath);
|
||||||
|
String serverHash = serverMeta.hash.replace("sha256:", "");
|
||||||
|
|
||||||
|
if (localHash != null && localHash.equals(serverHash)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localHash != null) {
|
||||||
|
log("Обновление: " + filePath);
|
||||||
|
} else {
|
||||||
|
log("Скачивание: " + filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(newVersion, filePath, serverMeta.size);
|
||||||
|
downloaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Обновлено файлов: " + downloaded + ", пропущено: " + skipped);
|
||||||
|
log("Обновлено до v" + newVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, FileMeta> fetchServerMeta(String version) {
|
||||||
|
Map<String, FileMeta> files = new HashMap<>();
|
||||||
|
try {
|
||||||
|
URL url = new URL(BASE_URL + "/launcher/meta/" + version);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setConnectTimeout(5000);
|
||||||
|
conn.setReadTimeout(10000);
|
||||||
|
|
||||||
|
if (conn.getResponseCode() == 200) {
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
|
|
||||||
|
com.google.gson.JsonObject json = com.google.gson.JsonParser.parseString(sb.toString()).getAsJsonObject();
|
||||||
|
com.google.gson.JsonArray filesArray = json.getAsJsonArray("files");
|
||||||
|
|
||||||
|
for (com.google.gson.JsonElement fileElem : filesArray) {
|
||||||
|
com.google.gson.JsonObject file = fileElem.getAsJsonObject();
|
||||||
|
files.put(file.get("path").getAsString(), new FileMeta(
|
||||||
|
file.get("hash").getAsString(),
|
||||||
|
file.get("size").getAsLong()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("Ошибка получения мета: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> scanLocalFiles() {
|
||||||
|
Map<String, String> files = new HashMap<>();
|
||||||
|
try {
|
||||||
|
Files.walk(baseDir)
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(p -> !p.toString().contains(".git"))
|
||||||
|
.forEach(path -> {
|
||||||
|
try {
|
||||||
|
String relativePath = baseDir.relativize(path).toString().replace("\\", "/");
|
||||||
|
String hash = calculateFileHash(path);
|
||||||
|
files.put(relativePath, hash);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
});
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String calculateFileHash(Path path) throws Exception {
|
||||||
|
try (InputStream is = Files.newInputStream(path)) {
|
||||||
|
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int len;
|
||||||
|
while ((len = is.read(buf)) > 0) {
|
||||||
|
digest.update(buf, 0, len);
|
||||||
|
}
|
||||||
|
byte[] hash = digest.digest();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (byte b : hash) sb.append(String.format("%02x", b));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
|
||||||
|
URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + filePath);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setConnectTimeout(10000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
|
||||||
|
if (conn.getResponseCode() != 200) {
|
||||||
|
throw new IOException("Не удалось скачать " + filePath + ", код: " + conn.getResponseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
Path outPath = baseDir.resolve(filePath);
|
||||||
|
Files.createDirectories(outPath.getParent());
|
||||||
|
|
||||||
|
long downloaded = 0;
|
||||||
|
try (InputStream in = conn.getInputStream();
|
||||||
|
OutputStream out = new FileOutputStream(outPath.toFile())) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int len;
|
||||||
|
while ((len = in.read(buf)) > 0) {
|
||||||
|
out.write(buf, 0, len);
|
||||||
|
downloaded += len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем хеш
|
||||||
|
String actualHash = calculateFileHash(outPath);
|
||||||
|
String expectedHash = expectedSize > 0 ? "" : "";
|
||||||
|
if (downloaded != expectedSize) {
|
||||||
|
log("Предупреждение: размер " + filePath + " не совпадает");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выводим прогресс
|
||||||
|
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FileMeta {
|
||||||
|
String hash;
|
||||||
|
long size;
|
||||||
|
FileMeta(String hash, long size) {
|
||||||
|
this.hash = hash;
|
||||||
|
this.size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchJFX() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = getLauncherJar();
|
||||||
|
|
||||||
|
log("Запуск JFX режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
// JVM аргументы для UTF-8 и JavaFX
|
||||||
|
List<String> jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Путь к JavaFX модулям
|
||||||
|
Path javafxPath = baseDir.resolve("lib").resolve("javafx");
|
||||||
|
if (Files.exists(javafxPath)) {
|
||||||
|
jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8",
|
||||||
|
"-Dlauncher.server=" + BASE_URL,
|
||||||
|
"--module-path", javafxPath.toAbsolutePath().toString(),
|
||||||
|
"--add-modules", "javafx.controls,javafx.web"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8",
|
||||||
|
"-Dlauncher.server=" + BASE_URL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(javaBin.toAbsolutePath().toString());
|
||||||
|
cmd.addAll(jvmArgs);
|
||||||
|
cmd.add("-jar");
|
||||||
|
cmd.add(jarPath.toAbsolutePath().toString());
|
||||||
|
cmd.add("--jfx");
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchCLI() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = getLauncherJar();
|
||||||
|
|
||||||
|
log("Запуск CLI режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
// JVM аргументы для UTF-8
|
||||||
|
List<String> jvmArgs = List.of(
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"-Dsun.stdout.encoding=UTF-8",
|
||||||
|
"-Dsun.stderr.encoding=UTF-8",
|
||||||
|
"-Dlauncher.server=" + BASE_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(javaBin.toAbsolutePath().toString());
|
||||||
|
cmd.addAll(jvmArgs);
|
||||||
|
cmd.add("-jar");
|
||||||
|
cmd.add(jarPath.toAbsolutePath().toString());
|
||||||
|
cmd.add("--cli");
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path findJava() {
|
||||||
|
String os = System.getProperty("os.name").toLowerCase();
|
||||||
|
String javaExe = os.contains("windows") ? "java.exe" : "java";
|
||||||
|
|
||||||
|
// Сначала ищем jre21/bin/java рядом с лаунчером
|
||||||
|
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||||
|
|
||||||
|
// Если нет, пробуем системную Java
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если и это не найдено - ищем java в PATH
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
try {
|
||||||
|
Process p = new ProcessBuilder("which", javaExe).start();
|
||||||
|
if (p.waitFor() == 0) {
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||||
|
String path = br.readLine();
|
||||||
|
if (path != null) {
|
||||||
|
javaBin = Paths.get(path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
throw new RuntimeException("Java не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
|
||||||
|
}
|
||||||
|
|
||||||
|
return javaBin;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
<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>
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.2.3</version>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<artifactId>maven-shade-plugin</artifactId>
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
<version>3.5.0</version>
|
<version>3.5.0</version>
|
||||||
@@ -24,7 +28,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>
|
||||||
@@ -45,10 +49,11 @@
|
|||||||
<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>
|
||||||
|
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||||
<jre>
|
<jre>
|
||||||
<path>jre21</path>
|
<path>jre21</path>
|
||||||
<minVersion>21</minVersion>
|
<minVersion>21</minVersion>
|
||||||
@@ -56,13 +61,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>
|
||||||
@@ -80,9 +85,15 @@
|
|||||||
<configuration>
|
<configuration>
|
||||||
<target>
|
<target>
|
||||||
<echo>${project.version}</echo>
|
<echo>${project.version}</echo>
|
||||||
|
<delete />
|
||||||
|
<mkdir />
|
||||||
<copy>
|
<copy>
|
||||||
<fileset />
|
<fileset>
|
||||||
|
<include />
|
||||||
|
<include />
|
||||||
|
</fileset>
|
||||||
</copy>
|
</copy>
|
||||||
|
<move />
|
||||||
<zip />
|
<zip />
|
||||||
</target>
|
</target>
|
||||||
</configuration>
|
</configuration>
|
||||||
@@ -91,10 +102,53 @@
|
|||||||
</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>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.10.1</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<artifactId>junit-jupiter-api</artifactId>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
</exclusion>
|
||||||
|
<exclusion>
|
||||||
|
<artifactId>junit-jupiter-params</artifactId>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
</exclusion>
|
||||||
|
<exclusion>
|
||||||
|
<artifactId>junit-jupiter-engine</artifactId>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.target>21</maven.compiler.target>
|
<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>
|
||||||
<maven.compiler.source>21</maven.compiler.source>
|
<maven.compiler.source>21</maven.compiler.source>
|
||||||
|
<project.organization.name>ZernMC</project.organization.name>
|
||||||
|
<maven.compiler.target>21</maven.compiler.target>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<project.inceptionYear>2026</project.inceptionYear>
|
||||||
</properties>
|
</properties>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>me.sashegdev</groupId>
|
||||||
|
<artifactId>ZernMCLauncher</artifactId>
|
||||||
|
<version>1.0.9</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>zernmclauncher</artifactId>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<name>ZernMC Launcher</name>
|
||||||
|
<description>Main launcher module with JFX UI</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- HTTP Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
|
<artifactId>httpclient</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JSON -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.json</groupId>
|
||||||
|
<artifactId>json</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Console/Terminal -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.fusesource.jansi</groupId>
|
||||||
|
<artifactId>jansi</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jline</groupId>
|
||||||
|
<artifactId>jline</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>me.tongfei</groupId>
|
||||||
|
<artifactId>progressbar</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- IO -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>commons-io</groupId>
|
||||||
|
<artifactId>commons-io</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JavaFX - Windows -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-controls</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-web</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-graphics</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-base</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-media</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>5.10.1</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outputFile>../../server/builds/zernmclauncher.jar</outputFile>
|
||||||
|
<transformers>
|
||||||
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
|
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
||||||
|
<manifestEntries>
|
||||||
|
<Implementation-Version>${project.version}</Implementation-Version>
|
||||||
|
<Implementation-Title>ZernMC Launcher</Implementation-Title>
|
||||||
|
</manifestEntries>
|
||||||
|
</transformer>
|
||||||
|
</transformers>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Launch4j для создания .exe из bootstrap JAR -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.akathist.maven.plugins.launch4j</groupId>
|
||||||
|
<artifactId>launch4j-maven-plugin</artifactId>
|
||||||
|
<version>2.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>l4j</id>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>launch4j</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<outfile>../../server/builds/zernmc-${project.version}.exe</outfile>
|
||||||
|
<jar>../../server/builds/zernmc-bootstrap.jar</jar>
|
||||||
|
<headerType>console</headerType>
|
||||||
|
<dontWrapJar>false</dontWrapJar>
|
||||||
|
<mainClass>me.sashegdev.zernmc.launcher.Bootstrap</mainClass>
|
||||||
|
<jre>
|
||||||
|
<path>lib/jre21</path>
|
||||||
|
<minVersion>21</minVersion>
|
||||||
|
</jre>
|
||||||
|
<versionInfo>
|
||||||
|
<fileVersion>${project.version}.0</fileVersion>
|
||||||
|
<txtFileVersion>${project.version}</txtFileVersion>
|
||||||
|
<fileDescription>ZernMC Launcher</fileDescription>
|
||||||
|
<productVersion>${project.version}.0</productVersion>
|
||||||
|
<txtProductVersion>${project.version}</txtProductVersion>
|
||||||
|
<productName>ZernMC</productName>
|
||||||
|
<companyName>ZernMC</companyName>
|
||||||
|
<internalName>zernmc</internalName>
|
||||||
|
<originalFilename>zernmc-${project.version}.exe</originalFilename>
|
||||||
|
</versionInfo>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Post-build: копирование JRE и создание ZIP -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-antrun-plugin</artifactId>
|
||||||
|
<version>3.1.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>run</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<target>
|
||||||
|
<echo file="../../server/builds/build.version">${project.version}</echo>
|
||||||
|
|
||||||
|
<!-- Удаляем старую папку lib если есть -->
|
||||||
|
<delete dir="../../server/builds/lib"/>
|
||||||
|
|
||||||
|
<!-- Создаем папку lib -->
|
||||||
|
<mkdir dir="../../server/builds/lib"/>
|
||||||
|
|
||||||
|
<!-- Копируем JRE в lib/jre21 -->
|
||||||
|
<copy todir="../../server/builds/lib/jre21" overwrite="true">
|
||||||
|
<fileset dir="${user.home}/launcher/jre/jre21">
|
||||||
|
<include name="*"/>
|
||||||
|
<include name="**/*"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
|
||||||
|
<!-- Копируем JavaFX модули в lib/javafx -->
|
||||||
|
<mkdir dir="../../server/builds/lib/javafx"/>
|
||||||
|
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||||
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-controls/21">
|
||||||
|
<include name="*win.jar"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||||
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-graphics/21">
|
||||||
|
<include name="*win.jar"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||||
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-base/21">
|
||||||
|
<include name="*win.jar"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||||
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-web/21">
|
||||||
|
<include name="*win.jar"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
<copy todir="../../server/builds/lib/javafx" overwrite="true">
|
||||||
|
<fileset dir="${user.home}/.m2/repository/org/openjfx/javafx-media/21">
|
||||||
|
<include name="*win.jar"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
|
||||||
|
<!-- Переименовываем exe для zip -->
|
||||||
|
<move file="../../server/builds/zernmc-${project.version}.exe"
|
||||||
|
tofile="../../server/builds/zernmc.exe" overwrite="true"/>
|
||||||
|
|
||||||
|
<!-- Создаем папку bin и копируем JAR -->
|
||||||
|
<mkdir dir="../../server/builds/bin"/>
|
||||||
|
<copy file="../../server/builds/zernmclauncher.jar"
|
||||||
|
tofile="../../server/builds/bin/zernmclauncher.jar" overwrite="true"/>
|
||||||
|
|
||||||
|
<!-- Копируем UI в assets -->
|
||||||
|
<mkdir dir="../../server/builds/assets"/>
|
||||||
|
<copy todir="../../server/builds/assets/ui" overwrite="true">
|
||||||
|
<fileset dir="${project.basedir}/src/resources/ui">
|
||||||
|
<include name="**/*"/>
|
||||||
|
</fileset>
|
||||||
|
</copy>
|
||||||
|
|
||||||
|
<!-- Создаём zip -->
|
||||||
|
<zip destfile="../../server/builds/ZernMC-win-${project.version}.zip"
|
||||||
|
basedir="../../server/builds"
|
||||||
|
includes="zernmc.exe,bin/**,assets/**,lib/**"
|
||||||
|
excludes="build.version,*-${project.version}.*,zernmclauncher.jar,zernmc-bootstrap.jar"/>
|
||||||
|
</target>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.file.*;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Bootstrap {
|
||||||
|
private static final String VERSION_FILE = "build.version";
|
||||||
|
private static final String JAR_NAME = "ZernMCLauncher.jar";
|
||||||
|
private static final String BASE_URL = "http://87.120.187.36:1582";
|
||||||
|
|
||||||
|
private static Path baseDir;
|
||||||
|
private static Path logDir;
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
baseDir = Paths.get("").toAbsolutePath();
|
||||||
|
logDir = baseDir.resolve("logs");
|
||||||
|
Files.createDirectories(logDir);
|
||||||
|
|
||||||
|
log("=== ZernMC Launcher ===");
|
||||||
|
|
||||||
|
// Определяем режим запуска
|
||||||
|
List<String> argList = Arrays.asList(args);
|
||||||
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
boolean jfxMode = !cliMode; // по умолчанию JFX
|
||||||
|
|
||||||
|
// Проверка и обновление лаунчера
|
||||||
|
String currentVersion = readCurrentVersion();
|
||||||
|
String serverVersion = getServerVersion();
|
||||||
|
|
||||||
|
log("Локальная версия: " + currentVersion);
|
||||||
|
log("Версия на сервере: " + serverVersion);
|
||||||
|
|
||||||
|
if (isNewer(serverVersion, currentVersion)) {
|
||||||
|
log("Доступно обновление!");
|
||||||
|
downloadUpdate(serverVersion);
|
||||||
|
} else {
|
||||||
|
log("Версия актуальна");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск в выбранном режиме
|
||||||
|
if (jfxMode) {
|
||||||
|
launchJFX();
|
||||||
|
} else {
|
||||||
|
launchCLI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void log(String msg) {
|
||||||
|
String entry = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) + "] " + msg;
|
||||||
|
System.out.println(entry);
|
||||||
|
try {
|
||||||
|
Files.writeString(logDir.resolve("launcher.log"), entry + "\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readCurrentVersion() {
|
||||||
|
Path f = baseDir.resolve(VERSION_FILE);
|
||||||
|
try {
|
||||||
|
if (Files.exists(f)) return Files.readString(f).trim();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getServerVersion() {
|
||||||
|
try {
|
||||||
|
URL url = new URL(BASE_URL.replace("download?type=jar", "version"));
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
if (conn.getResponseCode() == 200) {
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||||
|
String line = br.readLine();
|
||||||
|
if (line != null && line.contains("version")) {
|
||||||
|
return line.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isNewer(String server, String current) {
|
||||||
|
try {
|
||||||
|
String[] sa = server.split("\\.");
|
||||||
|
String[] ca = current.split("\\.");
|
||||||
|
for (int i = 0; i < Math.min(sa.length, ca.length); i++) {
|
||||||
|
int sv = Integer.parseInt(sa[i]);
|
||||||
|
int cv = Integer.parseInt(ca[i]);
|
||||||
|
if (sv > cv) return true;
|
||||||
|
if (sv < cv) return false;
|
||||||
|
}
|
||||||
|
return sa.length > ca.length;
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void downloadUpdate(String newVersion) throws Exception {
|
||||||
|
URL url = new URL(BASE_URL + "/launcher/download/jar");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
|
||||||
|
if (conn.getResponseCode() == 200) {
|
||||||
|
Path jarFile = baseDir.resolve(JAR_NAME);
|
||||||
|
Path tmp = jarFile.resolveSibling("zernmc-launcher-new.jar");
|
||||||
|
|
||||||
|
try (InputStream in = conn.getInputStream();
|
||||||
|
OutputStream out = new FileOutputStream(tmp.toFile())) {
|
||||||
|
byte[] buf = new byte[8192];
|
||||||
|
int len;
|
||||||
|
long total = 0;
|
||||||
|
while ((len = in.read(buf)) > 0) {
|
||||||
|
out.write(buf, 0, len);
|
||||||
|
total += len;
|
||||||
|
System.out.print("\rСкачано: " + (total/1024/1024) + " MB");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("Скачано");
|
||||||
|
|
||||||
|
Path backup = jarFile.resolveSibling(JAR_NAME + ".old");
|
||||||
|
|
||||||
|
if (Files.exists(jarFile)) Files.move(jarFile, backup, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
Files.move(tmp, jarFile, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
if (Files.exists(backup)) Files.delete(backup);
|
||||||
|
|
||||||
|
Files.writeString(baseDir.resolve(VERSION_FILE), newVersion);
|
||||||
|
log("Обновлено до v" + newVersion);
|
||||||
|
} else {
|
||||||
|
throw new IOException("Сервер вернул код: " + conn.getResponseCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchJFX() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||||
|
|
||||||
|
log("Запуск JFX режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
javaBin.toAbsolutePath().toString(),
|
||||||
|
"-jar",
|
||||||
|
jarPath.toAbsolutePath().toString(),
|
||||||
|
"--jfx"
|
||||||
|
);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchCLI() throws Exception {
|
||||||
|
Path javaBin = findJava();
|
||||||
|
Path jarPath = baseDir.resolve(JAR_NAME);
|
||||||
|
|
||||||
|
log("Запуск CLI режима...");
|
||||||
|
log("Java: " + javaBin);
|
||||||
|
log("JAR: " + jarPath);
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
|
javaBin.toAbsolutePath().toString(),
|
||||||
|
"-jar",
|
||||||
|
jarPath.toAbsolutePath().toString(),
|
||||||
|
"--cli"
|
||||||
|
);
|
||||||
|
pb.directory(baseDir.toFile());
|
||||||
|
pb.inheritIO();
|
||||||
|
Process p = pb.start();
|
||||||
|
int code = p.waitFor();
|
||||||
|
log("Завершено с кодом: " + code);
|
||||||
|
System.exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path findJava() {
|
||||||
|
String os = System.getProperty("os.name").toLowerCase();
|
||||||
|
String javaExe = os.contains("windows") ? "java.exe" : "java";
|
||||||
|
|
||||||
|
// Сначала ищем jre21/bin/java рядом с лаунчером
|
||||||
|
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
|
||||||
|
|
||||||
|
// Если нет, пробуем системную Java
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
javaBin = Paths.get(System.getProperty("java.home"), "bin", javaExe);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если и это не найдено - ищем java в PATH
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
try {
|
||||||
|
Process p = new ProcessBuilder("which", javaExe).start();
|
||||||
|
if (p.waitFor() == 0) {
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
|
||||||
|
String path = br.readLine();
|
||||||
|
if (path != null) {
|
||||||
|
javaBin = Paths.get(path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Files.exists(javaBin)) {
|
||||||
|
throw new RuntimeException("Java не найдена. Убедитесь, что jre21 присутствует в папке с лаунчером или Java установлена в системе");
|
||||||
|
}
|
||||||
|
|
||||||
|
return javaBin;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.menu.*;
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.*;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Main {
|
||||||
|
|
||||||
|
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
||||||
|
private static final LauncherAPI api = new LauncherAPI();
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException {
|
||||||
|
// Настройка кодировки для Windows и Linux
|
||||||
|
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
||||||
|
System.setProperty("file.encoding", "UTF-8");
|
||||||
|
System.setProperty("sun.err.encoding", "UTF-8");
|
||||||
|
System.setProperty("sun.stdout.encoding", "UTF-8");
|
||||||
|
|
||||||
|
// Для Windows CMD - пытаемся переключить в UTF-8 режим
|
||||||
|
try {
|
||||||
|
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
|
||||||
|
new ProcessBuilder("cmd", "/c", "chcp", "65001").inheritIO().start().waitFor();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
ZAnsi.install();
|
||||||
|
System.out.print("\033[H\033[2J");
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
||||||
|
|
||||||
|
// Определяем режим запуска
|
||||||
|
List<String> argList = List.of(args);
|
||||||
|
boolean jfxMode = argList.contains("--jfx");
|
||||||
|
boolean cliMode = argList.contains("--cli");
|
||||||
|
|
||||||
|
if (jfxMode) {
|
||||||
|
launchJFX();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI режим (по умолчанию или с --cli)
|
||||||
|
startCLI();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void launchJFX() {
|
||||||
|
System.out.println(ZAnsi.cyan("Запуск JFX интерфейса..."));
|
||||||
|
try {
|
||||||
|
// Устанавливаем параметры для JavaFX (важно для Windows)
|
||||||
|
System.setProperty("javafx.runtime.version", "21");
|
||||||
|
|
||||||
|
JFXLauncher.main(new String[]{});
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.brightRed("Ошибка запуска JFX: " + e.getMessage()));
|
||||||
|
// Проверяем, связано ли это с отсутствием JavaFX
|
||||||
|
if (e.getMessage() != null && e.getMessage().contains("QuantumRenderer")) {
|
||||||
|
System.err.println(ZAnsi.yellow("JavaFX недоступен. Возможно, отсутствуют нативные библиотеки."));
|
||||||
|
System.err.println(ZAnsi.yellow("Попробуйте использовать CLI режим: --cli"));
|
||||||
|
}
|
||||||
|
e.printStackTrace();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void startCLI() throws IOException {
|
||||||
|
// Проверка всех сервисов при старте
|
||||||
|
ZHttpClient.checkAllServicesOnStartup();
|
||||||
|
|
||||||
|
// === АВТОРИЗАЦИЯ (используем новый API) ===
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
||||||
|
var sessionResponse = api.checkSession();
|
||||||
|
|
||||||
|
if (!sessionResponse.isSuccess()) {
|
||||||
|
LoginMenu loginMenu = new LoginMenu();
|
||||||
|
boolean loggedIn = loginMenu.show();
|
||||||
|
if (!loggedIn) {
|
||||||
|
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||||
|
ZAnsi.uninstall();
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var sessionInfo = sessionResponse.getData();
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + sessionInfo.getUsername() + "!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// === ГЛАВНЫЙ ЦИКЛ ===
|
||||||
|
try {
|
||||||
|
mainLoop();
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
|
||||||
|
e.printStackTrace();
|
||||||
|
} finally {
|
||||||
|
ZAnsi.uninstall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== ГЛАВНЫЙ ЦИКЛ ======================
|
||||||
|
private static void mainLoop() throws Exception {
|
||||||
|
if (Config.isZernMCBuild()) {
|
||||||
|
zernMCFlow();
|
||||||
|
} else {
|
||||||
|
globalFlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== ZERNMC FLOW ======================
|
||||||
|
private static void zernMCFlow() throws Exception {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||||
|
|
||||||
|
// 1. Проверка подключения к серверу
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||||
|
try {
|
||||||
|
String response = ZHttpClient.get("/health");
|
||||||
|
System.out.println(ZAnsi.brightGreen("✓ Сервер доступен"));
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("✗ Не удалось подключиться к ZernMC серверу"));
|
||||||
|
System.out.println(ZAnsi.white("Ошибка: " + e.getMessage()));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Авторизация
|
||||||
|
boolean sessionRestored = AuthManager.loadSavedSession();
|
||||||
|
if (!sessionRestored) {
|
||||||
|
LoginMenu loginMenu = new LoginMenu();
|
||||||
|
boolean loggedIn = loginMenu.show();
|
||||||
|
if (!loggedIn) {
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Запуск меню (LaunchMenu сам определит режим и вызовет нужный flow)
|
||||||
|
LaunchMenu launchMenu = new LaunchMenu();
|
||||||
|
launchMenu.show(); // ← Здесь будет вызван showZernMCOnly() внутри
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== GLOBAL FLOW ======================
|
||||||
|
private static void globalFlow() throws Exception {
|
||||||
|
while (true) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("=== ZernMC Launcher ==="));
|
||||||
|
|
||||||
|
List<String> options = List.of(
|
||||||
|
"Запустить игру",
|
||||||
|
"Проверка обновлений",
|
||||||
|
"Настройки",
|
||||||
|
"Проверка подключения к серверам",
|
||||||
|
"Выход"
|
||||||
|
);
|
||||||
|
|
||||||
|
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
||||||
|
int choice = menu.show();
|
||||||
|
|
||||||
|
if (choice == -1 || choice == 4) {
|
||||||
|
System.out.println(ZAnsi.yellow("До свидания!"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 0 -> new LaunchMenu().show(); // обычный LaunchMenu
|
||||||
|
case 1 -> new UpdateMenu().show();
|
||||||
|
case 2 -> new SettingsMenu().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,74 @@
|
|||||||
|
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.launch.LaunchService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Центральный фасад для внутреннего API лаунчера.
|
||||||
|
* Используется как единая точка входа для UI и других компонентов.
|
||||||
|
*/
|
||||||
|
public class LauncherAPI {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final InstanceService instanceService;
|
||||||
|
private final LaunchService launchService;
|
||||||
|
|
||||||
|
public LauncherAPI() {
|
||||||
|
this.authService = new AuthService();
|
||||||
|
this.instanceService = new InstanceService();
|
||||||
|
this.launchService = new LaunchService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthService auth() {
|
||||||
|
return authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public InstanceService instances() {
|
||||||
|
return instanceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LaunchService launch() {
|
||||||
|
return launchService;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== Удобные методы ======================
|
||||||
|
|
||||||
|
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,142 @@
|
|||||||
|
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(),
|
||||||
|
AuthManager.getRole(),
|
||||||
|
AuthManager.getRoleName()
|
||||||
|
);
|
||||||
|
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;
|
||||||
|
private int role;
|
||||||
|
private String roleName;
|
||||||
|
|
||||||
|
public SessionInfo(String username, String token, boolean passActive, int role, String roleName) {
|
||||||
|
this.username = username;
|
||||||
|
this.token = token;
|
||||||
|
this.passActive = passActive;
|
||||||
|
this.role = role;
|
||||||
|
this.roleName = roleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public boolean isPassActive() { return passActive; }
|
||||||
|
public int getRole() { return role; }
|
||||||
|
public String getRoleName() { return roleName; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.api.launch;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.api.ApiResponse;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
JFXLauncher.initGameLog(instance.getPath());
|
||||||
|
|
||||||
|
LaunchCommandBuilder builder = new LaunchCommandBuilder(instance);
|
||||||
|
LaunchOptions options = new LaunchOptions();
|
||||||
|
|
||||||
|
// Set auth info
|
||||||
|
options.setUsername(AuthManager.getUsername());
|
||||||
|
options.setAccessToken(AuthManager.getAccessToken());
|
||||||
|
options.setUuid(AuthManager.getUuid());
|
||||||
|
|
||||||
|
List<String> command = builder.build(options);
|
||||||
|
System.out.println("[LAUNCH] Generated command for " + instanceName + ":");
|
||||||
|
command.forEach(arg -> System.out.println(" " + arg));
|
||||||
|
|
||||||
|
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||||
|
processBuilder.directory(instance.getPath().toFile());
|
||||||
|
processBuilder.redirectErrorStream(true);
|
||||||
|
|
||||||
|
Process process = processBuilder.start();
|
||||||
|
System.out.println("[LAUNCH] Process started, pid=" + process.pid());
|
||||||
|
|
||||||
|
// Capture output (stdout)
|
||||||
|
Thread outThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
System.out.println("[STDOUT] " + line);
|
||||||
|
JFXLauncher.appendGameLog(line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[STDOUT ERROR] " + e.getMessage());
|
||||||
|
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outThread.setDaemon(true);
|
||||||
|
outThread.start();
|
||||||
|
|
||||||
|
// Capture errors (stderr)
|
||||||
|
Thread errThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
System.out.println("[STDERR] " + line);
|
||||||
|
JFXLauncher.appendGameLog("[ERR] " + line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[STDERR ERROR] " + e.getMessage());
|
||||||
|
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
errThread.setDaemon(true);
|
||||||
|
errThread.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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
-46
@@ -10,9 +10,13 @@ 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.io.InputStream;
|
||||||
|
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 +24,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 +45,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 +56,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 +68,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 +82,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());
|
||||||
}
|
}
|
||||||
@@ -80,6 +94,7 @@ public class AuthManager {
|
|||||||
} 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) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,11 +133,13 @@ public class AuthManager {
|
|||||||
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 +153,89 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getRoleName() {
|
||||||
|
if (userInfo != null && userInfo.role_name != null) {
|
||||||
|
return userInfo.role_name;
|
||||||
|
}
|
||||||
|
return "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,20 +244,24 @@ 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();
|
||||||
|
InputStream 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())) {
|
if (is != null) {
|
||||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||||
|
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
responseBody = "No response body (status " + statusCode + ")";
|
||||||
}
|
}
|
||||||
|
|
||||||
return new SimpleHttpResponse(statusCode, responseBody);
|
return new SimpleHttpResponse(statusCode, responseBody);
|
||||||
@@ -183,19 +274,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 +288,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 +316,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 {
|
||||||
+446
-257
@@ -10,12 +10,15 @@ import me.sashegdev.zernmc.launcher.minecraft.installer.VersionInstaller;
|
|||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
|
import me.sashegdev.zernmc.launcher.minecraft.model.MinecraftVersion;
|
||||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||||
import me.sashegdev.zernmc.launcher.utils.Input;
|
import me.sashegdev.zernmc.launcher.utils.Input;
|
||||||
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;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -23,6 +26,151 @@ import java.util.stream.Collectors;
|
|||||||
public class LaunchMenu {
|
public class LaunchMenu {
|
||||||
|
|
||||||
public void show() throws Exception {
|
public void show() throws Exception {
|
||||||
|
if (Config.isZernMCBuild()) {
|
||||||
|
showZernMCOnly();
|
||||||
|
} else {
|
||||||
|
showGlobal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== ZERNMC BUILD ======================
|
||||||
|
private void showZernMCOnly() throws Exception {
|
||||||
|
while (true) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("=== ZernMC Private Launcher ==="));
|
||||||
|
System.out.println(ZAnsi.cyan("Доступны только серверные сборки"));
|
||||||
|
|
||||||
|
if (!awaitActivePass()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PackDownloader tempDownloader = new PackDownloader(null);
|
||||||
|
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||||
|
|
||||||
|
if (availablePacks.isEmpty()) {
|
||||||
|
System.out.println(ZAnsi.yellow("На данный момент нет доступных сборок на сервере."));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> options = availablePacks.stream()
|
||||||
|
.map(p -> String.format("%s [%s + %s v%d] — %d файлов",
|
||||||
|
p.getName(),
|
||||||
|
p.getMinecraftVersion(),
|
||||||
|
p.getLoaderType(),
|
||||||
|
p.getVersion(),
|
||||||
|
p.getFilesCount()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
options.add("Назад в главное меню");
|
||||||
|
|
||||||
|
ArrowMenu menu = new ArrowMenu("Выберите сборку", options);
|
||||||
|
int choice = menu.show();
|
||||||
|
|
||||||
|
if (choice == -1 || choice == options.size() - 1) return;
|
||||||
|
|
||||||
|
ServerPack selected = availablePacks.get(choice);
|
||||||
|
installAndRunServerPack(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean awaitActivePass() throws Exception {
|
||||||
|
if (AuthManager.hasActivePass()) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("✓ Активная проходка подтверждена"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
||||||
|
System.out.println(ZAnsi.white("Для доступа к сборкам ZernMC требуется активная проходка."));
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
openActivationWebsite();
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan("Ожидаем активацию проходки... (проверка каждые 10 секунд)"));
|
||||||
|
System.out.println(ZAnsi.white("Нажмите Enter для отмены"));
|
||||||
|
|
||||||
|
for (int i = 0; i < 60; i++) {
|
||||||
|
try {
|
||||||
|
if (System.in.available() > 0) {
|
||||||
|
Input.readLine();
|
||||||
|
System.out.println(ZAnsi.yellow("\nОжидание отменено."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
Thread.sleep(10000);
|
||||||
|
|
||||||
|
if (AuthManager.hasActivePass()) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("\n✓ Проходка успешно активирована!"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.print(ZAnsi.cyan("."));
|
||||||
|
if ((i + 1) % 6 == 0) System.out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.brightRed("\n\nВремя ожидания истекло."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openActivationWebsite() {
|
||||||
|
//String url = "https://launcher.ru.zernmc.ru/activate-pass";
|
||||||
|
String url = ZHttpClient.getBaseUrl() + "/activate-pass";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
|
||||||
|
Desktop.getDesktop().browse(new URI(url));
|
||||||
|
System.out.println(ZAnsi.cyan("Браузер открыт: " + url));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.yellow("Не удалось открыть браузер автоматически."));
|
||||||
|
System.out.println(ZAnsi.white("Откройте вручную: " + url));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("Ошибка открытия браузера: " + e.getMessage()));
|
||||||
|
System.out.println(ZAnsi.white("Ссылка: " + url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void installAndRunServerPack(ServerPack selected) throws Exception {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
|
||||||
|
System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() +
|
||||||
|
(selected.getLoaderVersion() != null ? " " + selected.getLoaderVersion() : ""));
|
||||||
|
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
|
||||||
|
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
|
||||||
|
|
||||||
|
String localName = askPackName();
|
||||||
|
if (localName == null) return;
|
||||||
|
|
||||||
|
if (InstanceManager.getInstance(localName) != null) {
|
||||||
|
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstanceManager.createInstanceFolder(localName);
|
||||||
|
Instance newInstance = InstanceManager.getInstance(localName);
|
||||||
|
|
||||||
|
PackDownloader packDownloader = new PackDownloader(newInstance);
|
||||||
|
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + localName + "' успешно установлена!"));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
|
||||||
|
launchExistingInstance(newInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== GLOBAL BUILD ======================
|
||||||
|
private void showGlobal() throws Exception {
|
||||||
while (true) {
|
while (true) {
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
List<Instance> instances = InstanceManager.getAllInstances();
|
List<Instance> instances = InstanceManager.getAllInstances();
|
||||||
@@ -37,11 +185,10 @@ public class LaunchMenu {
|
|||||||
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
|
ArrowMenu menu = new ArrowMenu("Управление сборками", options);
|
||||||
int choice = menu.show();
|
int choice = menu.show();
|
||||||
|
|
||||||
if (choice == -1) break;
|
if (choice == -1 || choice == options.size() - 1) break;
|
||||||
if (choice == options.size() - 1) break;
|
|
||||||
|
|
||||||
if (choice == instances.size()) {
|
if (choice == instances.size()) {
|
||||||
installNewPack();
|
installNewPackGlobal();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,14 +197,14 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void installNewPack() throws Exception {
|
private void installNewPackGlobal() throws Exception {
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
|
|
||||||
List<String> options = List.of(
|
List<String> options = List.of(
|
||||||
"Установить сборку с сервера ZernMC",
|
"Установить сборку с сервера ZernMC",
|
||||||
"Установить Vanilla Minecraft",
|
"Установить Vanilla Minecraft",
|
||||||
"Создать сборку вручную (Fabric/Forge)",
|
"Создать сборку вручную (Fabric/Forge)",
|
||||||
"Назад"
|
"Назад"
|
||||||
);
|
);
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
|
ArrowMenu menu = new ArrowMenu("Установка новой сборки", options);
|
||||||
@@ -66,49 +213,17 @@ public class LaunchMenu {
|
|||||||
if (choice == -1 || choice == 3) return;
|
if (choice == -1 || choice == 3) return;
|
||||||
|
|
||||||
switch (choice) {
|
switch (choice) {
|
||||||
case 0 -> {
|
case 0 -> installServerPackGlobal();
|
||||||
try {
|
|
||||||
installServerPack();
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Ошибка: " + e.getMessage()));
|
|
||||||
e.printStackTrace();
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 1 -> createVanillaInstance();
|
case 1 -> createVanillaInstance();
|
||||||
case 2 -> createCustomInstance();
|
case 2 -> createCustomInstance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void installServerPack() throws Exception {
|
private void installServerPackGlobal() throws Exception {
|
||||||
if (!AuthManager.hasActivePass()) {
|
if (!awaitActivePass()) return;
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
|
||||||
System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку."));
|
|
||||||
System.out.println();
|
|
||||||
System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: "));
|
|
||||||
|
|
||||||
String code = Input.readLine();
|
|
||||||
if (code.isEmpty()) return;
|
|
||||||
|
|
||||||
String result = AuthManager.activatePass(code);
|
|
||||||
System.out.println(ZAnsi.cyan(result));
|
|
||||||
|
|
||||||
if (!result.contains("успешно")) {
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Повторная проверка
|
|
||||||
if (!AuthManager.hasActivePass()) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Не удалось активировать проходку."));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.cyan("Получение списка доступных сборок с сервера..."));
|
System.out.println(ZAnsi.cyan("Получение списка доступных сборок..."));
|
||||||
|
|
||||||
PackDownloader tempDownloader = new PackDownloader(null);
|
PackDownloader tempDownloader = new PackDownloader(null);
|
||||||
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
List<ServerPack> availablePacks = tempDownloader.getAvailablePacks();
|
||||||
@@ -119,15 +234,14 @@ public class LaunchMenu {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Исправлено: убраны спецсимволы для Windows
|
|
||||||
List<String> options = availablePacks.stream()
|
List<String> options = availablePacks.stream()
|
||||||
.map(p -> String.format("%s [%s + %s v%d] - %d файлов",
|
.map(p -> String.format("%s [%s + %s v%d] — %d файлов",
|
||||||
p.getName(),
|
p.getName(),
|
||||||
p.getMinecraftVersion(),
|
p.getMinecraftVersion(),
|
||||||
p.getLoaderType(),
|
p.getLoaderType(),
|
||||||
p.getVersion(),
|
p.getVersion(),
|
||||||
p.getFilesCount()))
|
p.getFilesCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
options.add("Назад");
|
options.add("Назад");
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
|
ArrowMenu menu = new ArrowMenu("Выберите сборку для установки", options);
|
||||||
@@ -137,33 +251,22 @@ public class LaunchMenu {
|
|||||||
|
|
||||||
ServerPack selected = availablePacks.get(choice);
|
ServerPack selected = availablePacks.get(choice);
|
||||||
|
|
||||||
// Запрашиваем имя для локальной сборки
|
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
System.out.println(ZAnsi.header("Установка сборки: " + selected.getName()));
|
||||||
System.out.println(ZAnsi.white(" Minecraft: ") + selected.getMinecraftVersion());
|
|
||||||
System.out.println(ZAnsi.white(" Лоадер: ") + selected.getLoaderType() + " " + selected.getLoaderVersion());
|
|
||||||
System.out.println(ZAnsi.white(" Версия: v") + selected.getVersion());
|
|
||||||
System.out.println(ZAnsi.white(" Файлов: ") + selected.getFilesCount());
|
|
||||||
System.out.println();
|
|
||||||
|
|
||||||
System.out.print(ZAnsi.white("Введите название локальной сборки (Enter = использовать имя пака): "));
|
System.out.print(ZAnsi.white("\nВведите название локальной сборки (Enter = имя пака): "));
|
||||||
String localName = Input.readLine();
|
String localName = Input.readLine().trim();
|
||||||
if (localName.isEmpty()) {
|
if (localName.isEmpty()) localName = selected.getName();
|
||||||
localName = selected.getName();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, существует ли уже такая сборка
|
|
||||||
if (InstanceManager.getInstance(localName) != null) {
|
if (InstanceManager.getInstance(localName) != null) {
|
||||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||||
ConsoleUtils.pause();
|
ConsoleUtils.pause();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаем инстанс
|
|
||||||
InstanceManager.createInstanceFolder(localName);
|
InstanceManager.createInstanceFolder(localName);
|
||||||
Instance newInstance = InstanceManager.getInstance(localName);
|
Instance newInstance = InstanceManager.getInstance(localName);
|
||||||
|
|
||||||
// Устанавливаем сборку
|
|
||||||
PackDownloader packDownloader = new PackDownloader(newInstance);
|
PackDownloader packDownloader = new PackDownloader(newInstance);
|
||||||
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
boolean success = packDownloader.installOrUpdatePack(selected.getName(), selected);
|
||||||
|
|
||||||
@@ -176,151 +279,7 @@ public class LaunchMenu {
|
|||||||
ConsoleUtils.pause();
|
ConsoleUtils.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createVanillaInstance() throws Exception {
|
// ====================== manageInstance — полностью восстановлен ======================
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
|
||||||
|
|
||||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
|
||||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
|
||||||
|
|
||||||
List<String> versionOptions = allVersions.stream()
|
|
||||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
versionOptions.add("Назад");
|
|
||||||
|
|
||||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
|
||||||
int versionChoice = versionMenu.show();
|
|
||||||
|
|
||||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
|
||||||
|
|
||||||
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
|
||||||
String mcVersion = selectedMc.getId();
|
|
||||||
|
|
||||||
String packName = askPackName();
|
|
||||||
if (packName == null) return;
|
|
||||||
|
|
||||||
if (InstanceManager.getInstance(packName) != null) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
InstanceManager.createInstanceFolder(packName);
|
|
||||||
Instance newInstance = InstanceManager.getInstance(packName);
|
|
||||||
|
|
||||||
MinecraftLib lib = new MinecraftLib(newInstance);
|
|
||||||
boolean success = lib.installMinecraft(mcVersion);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
|
|
||||||
}
|
|
||||||
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void createCustomInstance() throws Exception {
|
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
|
||||||
|
|
||||||
VersionInstaller versionInstaller = new VersionInstaller(null);
|
|
||||||
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
|
||||||
|
|
||||||
List<String> versionOptions = allVersions.stream()
|
|
||||||
.map(v -> v.getId() + " (" + v.getType() + ")")
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
versionOptions.add("Назад");
|
|
||||||
|
|
||||||
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
|
||||||
int versionChoice = versionMenu.show();
|
|
||||||
|
|
||||||
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
|
||||||
|
|
||||||
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
|
||||||
String mcVersion = selectedMc.getId();
|
|
||||||
|
|
||||||
// === Выбор лоадера с правильной проверкой поддержки ===
|
|
||||||
List<String> loaderOptions = buildLoaderOptions(mcVersion);
|
|
||||||
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
|
|
||||||
int loaderChoice = loaderMenu.show();
|
|
||||||
|
|
||||||
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
|
|
||||||
|
|
||||||
String selectedLoader = loaderOptions.get(loaderChoice);
|
|
||||||
|
|
||||||
if (selectedLoader.contains("Vanilla")) {
|
|
||||||
createVanillaInstance();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String loaderType = selectedLoader.contains("Fabric") ? "fabric" : "forge";
|
|
||||||
|
|
||||||
String loaderVersion;
|
|
||||||
if (loaderType.equals("fabric")) {
|
|
||||||
loaderVersion = askFabricLoaderVersion();
|
|
||||||
} else {
|
|
||||||
loaderVersion = askForgeVersion(mcVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loaderVersion == null) return;
|
|
||||||
|
|
||||||
String packName = askPackName();
|
|
||||||
if (packName == null) return;
|
|
||||||
|
|
||||||
if (InstanceManager.getInstance(packName) != null) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
InstanceManager.createInstanceFolder(packName);
|
|
||||||
Instance newInstance = InstanceManager.getInstance(packName);
|
|
||||||
|
|
||||||
MinecraftLib lib = new MinecraftLib(newInstance);
|
|
||||||
|
|
||||||
boolean success = loaderType.equals("fabric")
|
|
||||||
? lib.installFabric(mcVersion, loaderVersion)
|
|
||||||
: lib.installForge(mcVersion, loaderVersion);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
|
||||||
}
|
|
||||||
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== Вспомогательные методы ======================
|
|
||||||
|
|
||||||
private List<String> buildLoaderOptions(String mcVersion) {
|
|
||||||
List<String> options = new ArrayList<>();
|
|
||||||
|
|
||||||
if (isFabricSupported(mcVersion)) {
|
|
||||||
options.add("Fabric");
|
|
||||||
}
|
|
||||||
if (isForgeSupported(mcVersion)) {
|
|
||||||
options.add("Forge");
|
|
||||||
}
|
|
||||||
options.add("Vanilla");
|
|
||||||
options.add("Назад");
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isFabricSupported(String version) {
|
|
||||||
return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isForgeSupported(String version) {
|
|
||||||
if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") ||
|
|
||||||
version.matches("^1\\.20.*") || version.matches("^1\\.21.*");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void manageInstance(Instance instance) throws Exception {
|
private void manageInstance(Instance instance) throws Exception {
|
||||||
while (true) {
|
while (true) {
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
@@ -392,7 +351,6 @@ public class LaunchMenu {
|
|||||||
} else {
|
} else {
|
||||||
System.out.println(ZAnsi.yellow("Обновление отменено."));
|
System.out.println(ZAnsi.yellow("Обновление отменено."));
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsoleUtils.pause();
|
ConsoleUtils.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +370,8 @@ public class LaunchMenu {
|
|||||||
String newLoaderVersion;
|
String newLoaderVersion;
|
||||||
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
||||||
newLoaderVersion = askFabricLoaderVersion();
|
newLoaderVersion = askFabricLoaderVersion();
|
||||||
|
} else if ("neoforge".equalsIgnoreCase(currentLoader)) {
|
||||||
|
newLoaderVersion = askNeoForgeVersion(mcVersion);
|
||||||
} else {
|
} else {
|
||||||
newLoaderVersion = askForgeVersion(mcVersion);
|
newLoaderVersion = askForgeVersion(mcVersion);
|
||||||
}
|
}
|
||||||
@@ -426,6 +386,8 @@ public class LaunchMenu {
|
|||||||
try {
|
try {
|
||||||
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
if ("fabric".equalsIgnoreCase(currentLoader)) {
|
||||||
success = lib.installFabric(mcVersion, newLoaderVersion);
|
success = lib.installFabric(mcVersion, newLoaderVersion);
|
||||||
|
} else if ("neoforge".equalsIgnoreCase(currentLoader)) {
|
||||||
|
success = lib.installNeoForge(mcVersion, newLoaderVersion);
|
||||||
} else {
|
} else {
|
||||||
success = lib.installForge(mcVersion, newLoaderVersion);
|
success = lib.installForge(mcVersion, newLoaderVersion);
|
||||||
}
|
}
|
||||||
@@ -446,13 +408,13 @@ public class LaunchMenu {
|
|||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
|
|
||||||
List<String> confirmOptions = List.of(
|
List<String> confirmOptions = List.of(
|
||||||
"Да, удалить сборку",
|
"Да, удалить сборку",
|
||||||
"Нет, отменить"
|
"Нет, отменить"
|
||||||
);
|
);
|
||||||
|
|
||||||
ArrowMenu confirmMenu = new ArrowMenu(
|
ArrowMenu confirmMenu = new ArrowMenu(
|
||||||
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
|
"Вы действительно хотите удалить сборку '" + instance.getName() + "'?",
|
||||||
confirmOptions
|
confirmOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
int choice = confirmMenu.show();
|
int choice = confirmMenu.show();
|
||||||
@@ -471,6 +433,203 @@ public class LaunchMenu {
|
|||||||
ConsoleUtils.pause();
|
ConsoleUtils.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void launchExistingInstance(Instance instance) {
|
||||||
|
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
||||||
|
|
||||||
|
MinecraftLib lib = new MinecraftLib(instance);
|
||||||
|
LaunchOptions options = new LaunchOptions();
|
||||||
|
|
||||||
|
options.setUsername(AuthManager.getUsername());
|
||||||
|
options.setUuid(AuthManager.getUuid());
|
||||||
|
options.setAccessToken(AuthManager.getAccessToken());
|
||||||
|
|
||||||
|
try {
|
||||||
|
lib.launch(options);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== Остальные вспомогательные методы ======================
|
||||||
|
|
||||||
|
private String askPackName() {
|
||||||
|
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
|
||||||
|
String name = Input.readLine().trim();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
System.out.println(ZAnsi.yellow("Отменено."));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createVanillaInstance() throws Exception {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||||
|
|
||||||
|
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||||
|
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||||
|
|
||||||
|
List<String> versionOptions = allVersions.stream()
|
||||||
|
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
versionOptions.add("Назад");
|
||||||
|
|
||||||
|
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||||
|
int versionChoice = versionMenu.show();
|
||||||
|
|
||||||
|
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||||
|
|
||||||
|
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
||||||
|
String mcVersion = selectedMc.getId();
|
||||||
|
|
||||||
|
String packName = askPackName();
|
||||||
|
if (packName == null) return;
|
||||||
|
|
||||||
|
if (InstanceManager.getInstance(packName) != null) {
|
||||||
|
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstanceManager.createInstanceFolder(packName);
|
||||||
|
Instance newInstance = InstanceManager.getInstance(packName);
|
||||||
|
|
||||||
|
MinecraftLib lib = new MinecraftLib(newInstance);
|
||||||
|
boolean success = lib.installMinecraft(mcVersion);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("\n[OK] Vanilla сборка '" + packName + "' успешно создана!"));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось создать сборку."));
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createCustomInstance() throws Exception {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.cyan("Получение списка версий Minecraft..."));
|
||||||
|
|
||||||
|
VersionInstaller versionInstaller = new VersionInstaller(null);
|
||||||
|
List<MinecraftVersion> allVersions = versionInstaller.getAvailableVersions();
|
||||||
|
|
||||||
|
List<String> versionOptions = allVersions.stream()
|
||||||
|
.map(v -> v.getId() + " (" + v.getType() + ")")
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
versionOptions.add("Назад");
|
||||||
|
|
||||||
|
ArrowMenu versionMenu = new ArrowMenu("Выбор версии Minecraft", versionOptions);
|
||||||
|
int versionChoice = versionMenu.show();
|
||||||
|
|
||||||
|
if (versionChoice == -1 || versionChoice == versionOptions.size() - 1) return;
|
||||||
|
|
||||||
|
MinecraftVersion selectedMc = allVersions.get(versionChoice);
|
||||||
|
String mcVersion = selectedMc.getId();
|
||||||
|
|
||||||
|
List<String> loaderOptions = buildLoaderOptions(mcVersion);
|
||||||
|
ArrowMenu loaderMenu = new ArrowMenu("Выбор модлоадера для " + mcVersion, loaderOptions);
|
||||||
|
int loaderChoice = loaderMenu.show();
|
||||||
|
|
||||||
|
if (loaderChoice == -1 || loaderChoice == loaderOptions.size() - 1) return;
|
||||||
|
|
||||||
|
String selectedLoader = loaderOptions.get(loaderChoice);
|
||||||
|
|
||||||
|
if (selectedLoader.contains("Vanilla")) {
|
||||||
|
createVanillaInstance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String loaderType;
|
||||||
|
if (selectedLoader.contains("Fabric")) {
|
||||||
|
loaderType = "fabric";
|
||||||
|
} else if (selectedLoader.contains("NeoForge")) {
|
||||||
|
loaderType = "neoforge";
|
||||||
|
} else {
|
||||||
|
loaderType = "forge";
|
||||||
|
}
|
||||||
|
|
||||||
|
String loaderVersion;
|
||||||
|
if (loaderType.equals("fabric")) {
|
||||||
|
loaderVersion = askFabricLoaderVersion();
|
||||||
|
} else if (loaderType.equals("neoforge")) {
|
||||||
|
loaderVersion = askNeoForgeVersion(mcVersion);
|
||||||
|
} else {
|
||||||
|
loaderVersion = askForgeVersion(mcVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaderVersion == null) return;
|
||||||
|
|
||||||
|
String packName = askPackName();
|
||||||
|
if (packName == null) return;
|
||||||
|
|
||||||
|
if (InstanceManager.getInstance(packName) != null) {
|
||||||
|
System.out.println(ZAnsi.brightRed("Сборка с таким именем уже существует!"));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InstanceManager.createInstanceFolder(packName);
|
||||||
|
Instance newInstance = InstanceManager.getInstance(packName);
|
||||||
|
|
||||||
|
MinecraftLib lib = new MinecraftLib(newInstance);
|
||||||
|
|
||||||
|
boolean success;
|
||||||
|
if (loaderType.equals("fabric")) {
|
||||||
|
success = lib.installFabric(mcVersion, loaderVersion);
|
||||||
|
} else if (loaderType.equals("neoforge")) {
|
||||||
|
success = lib.installNeoForge(mcVersion, loaderVersion);
|
||||||
|
} else {
|
||||||
|
success = lib.installForge(mcVersion, loaderVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("\n[OK] Сборка '" + packName + "' успешно установлена!"));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightRed("\n[FAIL] Не удалось установить сборку."));
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> buildLoaderOptions(String mcVersion) {
|
||||||
|
List<String> options = new ArrayList<>();
|
||||||
|
|
||||||
|
if (isFabricSupported(mcVersion)) options.add("Fabric");
|
||||||
|
if (isNeoForgeSupported(mcVersion)) options.add("NeoForge");
|
||||||
|
if (isForgeSupported(mcVersion)) options.add("Forge");
|
||||||
|
options.add("Vanilla");
|
||||||
|
options.add("Назад");
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isFabricSupported(String version) {
|
||||||
|
return version.matches("^1\\.(1[4-9]|[2-9]\\d).*");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isForgeSupported(String version) {
|
||||||
|
if (version.matches("^1\\.2[2-9].*") || version.matches("^\\d{2}.*")) return false;
|
||||||
|
return version.matches("^1\\.(1[2-9]|[2-9]\\d).*") ||
|
||||||
|
version.matches("^1\\.20.*") || version.matches("^1\\.21.*");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNeoForgeSupported(String version) {
|
||||||
|
return version.matches("^1\\.20\\.[1-9].*") ||
|
||||||
|
version.matches("^1\\.21.*") ||
|
||||||
|
version.matches("^\\d{2}\\..*");
|
||||||
|
}
|
||||||
|
|
||||||
private String askFabricLoaderVersion() throws Exception {
|
private String askFabricLoaderVersion() throws Exception {
|
||||||
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
|
System.out.println(ZAnsi.cyan("Получение списка версий Fabric Loader..."));
|
||||||
List<String> versions = ZHttpClient.getFabricLoaderVersions();
|
List<String> versions = ZHttpClient.getFabricLoaderVersions();
|
||||||
@@ -518,48 +677,8 @@ public class LaunchMenu {
|
|||||||
return compatibleVersions.get(choice);
|
return compatibleVersions.get(choice);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String askPackName() {
|
|
||||||
System.out.print(ZAnsi.white("\nВведите название новой сборки: "));
|
|
||||||
String name = Input.readLine();
|
|
||||||
if (name.isEmpty()) {
|
|
||||||
System.out.println(ZAnsi.yellow("Отменено."));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void launchExistingInstance(Instance instance) {
|
|
||||||
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
System.out.println(ZAnsi.brightGreen("Запуск сборки: " + instance.getName()));
|
|
||||||
|
|
||||||
MinecraftLib lib = new MinecraftLib(instance);
|
|
||||||
LaunchOptions options = new LaunchOptions();
|
|
||||||
|
|
||||||
// Авторизация Minecraft
|
|
||||||
options.setUsername(AuthManager.getUsername());
|
|
||||||
options.setUuid(AuthManager.getUuid());
|
|
||||||
options.setAccessToken(AuthManager.getAccessToken());
|
|
||||||
|
|
||||||
try {
|
|
||||||
lib.launch(options);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Ошибка при запуске: " + e.getMessage()));
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> getAllForgeVersions() throws Exception {
|
private List<String> getAllForgeVersions() throws Exception {
|
||||||
String metadataUrl = "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml";
|
String xml = ZHttpClient.downloadString("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml");
|
||||||
|
|
||||||
String xml = ZHttpClient.downloadString(metadataUrl);
|
|
||||||
|
|
||||||
List<String> versions = new ArrayList<>();
|
List<String> versions = new ArrayList<>();
|
||||||
int index = 0;
|
int index = 0;
|
||||||
@@ -575,7 +694,77 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versions.sort((a, b) -> b.compareTo(a));
|
versions.sort((a, b) -> b.compareTo(a));
|
||||||
|
return versions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String askNeoForgeVersion(String mcVersion) throws Exception {
|
||||||
|
System.out.println(ZAnsi.cyan("Получение списка версий NeoForge для " + mcVersion + "..."));
|
||||||
|
|
||||||
|
List<String> allNeoForgeVersions = getAllNeoForgeVersions();
|
||||||
|
|
||||||
|
List<String> compatibleVersions = allNeoForgeVersions.stream()
|
||||||
|
.filter(v -> isNeoForgeVersionCompatible(v, mcVersion))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (compatibleVersions.isEmpty()) {
|
||||||
|
System.out.println(ZAnsi.yellow("Не найдено совместимых версий NeoForge для " + mcVersion));
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> options = compatibleVersions.stream()
|
||||||
|
.limit(30)
|
||||||
|
.map(v -> "NeoForge " + v)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
options.add("Назад");
|
||||||
|
|
||||||
|
ArrowMenu menu = new ArrowMenu("Выбор версии NeoForge для " + mcVersion, options);
|
||||||
|
int choice = menu.show();
|
||||||
|
|
||||||
|
if (choice == -1 || choice == options.size() - 1) return null;
|
||||||
|
|
||||||
|
return compatibleVersions.get(choice);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNeoForgeVersionCompatible(String version, String mcVersion) {
|
||||||
|
if (mcVersion.equals("1.20.1")) {
|
||||||
|
return version.startsWith("47.");
|
||||||
|
}
|
||||||
|
String majorMinor = mcVersion.replace("1.", "");
|
||||||
|
String[] parts = majorMinor.split("\\.");
|
||||||
|
int targetMajor = Integer.parseInt(parts[0]);
|
||||||
|
return version.startsWith(targetMajor + ".");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getAllNeoForgeVersions() throws Exception {
|
||||||
|
List<String> versions = new ArrayList<>();
|
||||||
|
|
||||||
|
String[] mavenUrls = {
|
||||||
|
"https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
|
||||||
|
"https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
for (String mavenUrl : mavenUrls) {
|
||||||
|
try {
|
||||||
|
String xml = ZHttpClient.downloadString(mavenUrl);
|
||||||
|
int index = 0;
|
||||||
|
while ((index = xml.indexOf("<version>", index)) != -1) {
|
||||||
|
int start = index + 9;
|
||||||
|
int end = xml.indexOf("</version>", start);
|
||||||
|
if (end == -1) break;
|
||||||
|
|
||||||
|
String version = xml.substring(start, end).trim();
|
||||||
|
if (!versions.contains(version)) {
|
||||||
|
versions.add(version);
|
||||||
|
}
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Skip if one maven doesn't have the artifact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
versions.sort((a, b) -> b.compareTo(a));
|
||||||
return versions;
|
return versions;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+47
-7
@@ -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();
|
||||||
|
if (next == 91) { // '[' — arrow key sequence
|
||||||
|
passTerminal.reader().read(); // 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() {
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.menu;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class ServerCheckMenu {
|
||||||
|
|
||||||
|
public void show() throws IOException {
|
||||||
|
while (true) {
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
System.out.println(ZAnsi.header("Диагностика подключения"));
|
||||||
|
|
||||||
|
List<String> options = List.of(
|
||||||
|
"Проверить подключение к ZernMC серверу",
|
||||||
|
"Проверить доступ к Mojang (Minecraft)",
|
||||||
|
"Проверить доступ к Fabric Meta",
|
||||||
|
"Проверить доступ к Forge Maven",
|
||||||
|
"Назад в главное меню"
|
||||||
|
);
|
||||||
|
|
||||||
|
ArrowMenu menu = new ArrowMenu("Выберите проверку", options);
|
||||||
|
int choice = menu.show();
|
||||||
|
|
||||||
|
if (choice == -1 || choice == 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.clearScreen();
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 0 -> checkZernServer();
|
||||||
|
case 1 -> checkMojang();
|
||||||
|
case 2 -> checkFabric();
|
||||||
|
case 3 -> checkForge();
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsoleUtils.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkZernServer() {
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
||||||
|
|
||||||
|
try {
|
||||||
|
String response = ZHttpClient.get("/health");
|
||||||
|
System.out.println(ZAnsi.brightGreen("[OK] ZernMC сервер успешно подключён!"));
|
||||||
|
System.out.println(ZAnsi.white("Ответ сервера: ") + response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Не удалось подключиться к ZernMC серверу"));
|
||||||
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkMojang() {
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("[OK] Mojang доступен"));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Mojang вернул код " + response.statusCode()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Mojang"));
|
||||||
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkFabric() {
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://meta.fabricmc.net/v2/versions"))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("[OK] Fabric Meta доступен"));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Fabric Meta вернул код " + response.statusCode()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Fabric Meta"));
|
||||||
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkForge() {
|
||||||
|
System.out.println(ZAnsi.cyan("Проверка доступа к Forge Maven..."));
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpClient client = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml"))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
System.out.println(ZAnsi.brightGreen("[OK] Forge Maven доступен"));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Forge Maven вернул код " + response.statusCode()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.brightRed("[FAIL] Нет доступа к Forge Maven"));
|
||||||
|
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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; // флаг, что это сборка с сервера
|
||||||
+56
-6
@@ -2,13 +2,18 @@ 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;
|
||||||
|
import me.sashegdev.zernmc.launcher.ui.jfx.JFXLauncher;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -41,6 +46,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 +86,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 и скачивание модов
|
||||||
@@ -99,15 +118,43 @@ public class MinecraftLib {
|
|||||||
|
|
||||||
ProcessBuilder pb = new ProcessBuilder(command);
|
ProcessBuilder pb = new ProcessBuilder(command);
|
||||||
pb.directory(instance.getPath().toFile());
|
pb.directory(instance.getPath().toFile());
|
||||||
pb.redirectOutput(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
pb.redirectError(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
pb.redirectInput(ProcessBuilder.Redirect.INHERIT);
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
System.out.println(ZAnsi.brightGreen("\nЗапускаем Minecraft...\n"));
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
|
|
||||||
Process process = pb.start();
|
Process process = pb.start();
|
||||||
|
|
||||||
|
// Capture output
|
||||||
|
Thread outThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
JFXLauncher.appendGameLog(line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
JFXLauncher.appendGameLog("[Ошибка чтения вывода: " + e.getMessage() + "]");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
outThread.setDaemon(true);
|
||||||
|
outThread.start();
|
||||||
|
|
||||||
|
// Capture errors
|
||||||
|
Thread errThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
JFXLauncher.appendGameLog("[ERR] " + line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
JFXLauncher.appendGameLog("[Ошибка чтения ошибок: " + e.getMessage() + "]");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
errThread.setDaemon(true);
|
||||||
|
errThread.start();
|
||||||
|
|
||||||
int exitCode = process.waitFor();
|
int exitCode = process.waitFor();
|
||||||
|
outThread.join(1000);
|
||||||
|
errThread.join(1000);
|
||||||
|
|
||||||
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
System.out.println(ZAnsi.yellow("\nMinecraft завершился с кодом: " + exitCode));
|
||||||
}
|
}
|
||||||
@@ -129,7 +176,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 +211,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 версий
|
||||||
+91
-68
@@ -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,70 +39,86 @@ 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));
|
}
|
||||||
|
if (!AuthManager.canViewPacks()) {
|
||||||
JsonObject root = JsonParser.parseString(response).getAsJsonObject();
|
throw new IOException("Для просмотра сборок требуется активная проходка");
|
||||||
|
|
||||||
// Проверяем, есть ли поле "packs"
|
|
||||||
if (!root.has("packs")) {
|
|
||||||
System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа"));
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Используем 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пропускаем паки со статусом not_scanned
|
|
||||||
if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) {
|
|
||||||
System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере"));
|
|
||||||
continue;
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
||||||
@@ -293,21 +317,18 @@ public class PackDownloader {
|
|||||||
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
private 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("/")) {
|
if (!AuthManager.canDownloadPacks()) {
|
||||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
throw new IOException("Для скачивания сборок требуется активная проходка");
|
||||||
}
|
}
|
||||||
String url = baseUrl + "/pack/" + packName + "/diff";
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("URL: " + url));
|
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
|
||||||
|
|
||||||
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding
|
|
||||||
// РЕШЕНИЕ: используем 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,6 +336,7 @@ 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);
|
||||||
@@ -328,7 +350,6 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int responseCode = connection.getResponseCode();
|
int responseCode = connection.getResponseCode();
|
||||||
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
|
|
||||||
|
|
||||||
// Читаем ответ
|
// Читаем ответ
|
||||||
StringBuilder response = new StringBuilder();
|
StringBuilder response = new StringBuilder();
|
||||||
@@ -341,10 +362,13 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String responseBody = response.toString();
|
String responseBody = response.toString();
|
||||||
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
|
|
||||||
|
if (responseCode == 403) {
|
||||||
|
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
|
||||||
|
}
|
||||||
|
|
||||||
if (responseCode != 200) {
|
if (responseCode != 200) {
|
||||||
throw new IOException("HTTP " + responseCode + ": " + responseBody);
|
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
|
||||||
}
|
}
|
||||||
|
|
||||||
return gson.fromJson(responseBody, DiffResponse.class);
|
return gson.fromJson(responseBody, DiffResponse.class);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+493
@@ -0,0 +1,493 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
||||||
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class LaunchCommandBuilder {
|
||||||
|
|
||||||
|
private final Instance instance;
|
||||||
|
|
||||||
|
public LaunchCommandBuilder(Instance instance) {
|
||||||
|
this.instance = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> build(LaunchOptions options) throws Exception {
|
||||||
|
System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
|
||||||
|
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
|
||||||
|
String javaPath = "java";
|
||||||
|
command.add(javaPath);
|
||||||
|
|
||||||
|
command.addAll(getJvmArguments(options));
|
||||||
|
|
||||||
|
Path nativesDir = instance.getPath().resolve("natives");
|
||||||
|
if (!Files.exists(nativesDir)) {
|
||||||
|
Files.createDirectories(nativesDir);
|
||||||
|
}
|
||||||
|
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
||||||
|
|
||||||
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
boolean isModloader = "fabric".equals(loaderType) || "forge".equals(loaderType) || "neoforge".equals(loaderType);
|
||||||
|
|
||||||
|
VersionManifest manifest = resolveVersionManifest();
|
||||||
|
|
||||||
|
// For modloaders, always use vanilla classpath with all libraries
|
||||||
|
if (isModloader) {
|
||||||
|
System.out.println(ZAnsi.cyan(" Modloader detected (" + loaderType + "), using vanilla classpath"));
|
||||||
|
command.add("-cp");
|
||||||
|
command.add(buildVanillaClasspath());
|
||||||
|
command.add(getVanillaMainClass());
|
||||||
|
command.addAll(getVanillaGameArguments(options));
|
||||||
|
} else if (manifest != null) {
|
||||||
|
String classpath = buildClasspathFromManifest(manifest);
|
||||||
|
|
||||||
|
// Fallback if classpath is empty
|
||||||
|
if (classpath.isEmpty() || classpath.equals(instance.getPath().resolve("versions").resolve(getVersionId()).resolve(getVersionId() + ".jar").toAbsolutePath().toString())) {
|
||||||
|
System.out.println(ZAnsi.yellow(" manifest classpath пустой, использую vanilla classpath"));
|
||||||
|
command.add("-cp");
|
||||||
|
command.add(buildVanillaClasspath());
|
||||||
|
command.add(getVanillaMainClass());
|
||||||
|
command.addAll(getVanillaGameArguments(options));
|
||||||
|
} else {
|
||||||
|
command.add("-cp");
|
||||||
|
command.add(classpath);
|
||||||
|
|
||||||
|
String mainClass = resolveMainClass(manifest);
|
||||||
|
command.add(mainClass);
|
||||||
|
|
||||||
|
command.addAll(resolveGameArguments(manifest, options));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
command.add("-cp");
|
||||||
|
command.add(buildVanillaClasspath());
|
||||||
|
command.add(getVanillaMainClass());
|
||||||
|
command.addAll(getVanillaGameArguments(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
private VersionManifest resolveVersionManifest() {
|
||||||
|
try {
|
||||||
|
Path versionJson = findVersionJson();
|
||||||
|
if (versionJson != null && Files.exists(versionJson)) {
|
||||||
|
String content = Files.readString(versionJson);
|
||||||
|
JSONObject json = new JSONObject(content);
|
||||||
|
System.out.println(ZAnsi.green("Найден version.json: " + versionJson.getFileName()));
|
||||||
|
return new VersionManifest(json);
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.yellow("version.json не найден для " + instance.getName()));
|
||||||
|
System.out.println(ZAnsi.yellow(" loaderType=" + instance.getLoaderType() + " mcVersion=" + instance.getMinecraftVersion() + " loaderVersion=" + instance.getLoaderVersion()));
|
||||||
|
System.out.println(ZAnsi.yellow(" path=" + instance.getPath()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println(ZAnsi.yellow("Не удалось загрузить version.json: " + e.getMessage()));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path findVersionJson() {
|
||||||
|
Path versionsDir = instance.getPath().resolve("versions");
|
||||||
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
String mcVersion = instance.getMinecraftVersion();
|
||||||
|
String loaderVersion = instance.getLoaderVersion();
|
||||||
|
|
||||||
|
if ("fabric".equals(loaderType)) {
|
||||||
|
String versionId = getVersionId();
|
||||||
|
// Try fabric version ID first
|
||||||
|
Path jsonPath = versionsDir.resolve(versionId).resolve(versionId + ".json");
|
||||||
|
if (Files.exists(jsonPath)) {
|
||||||
|
return jsonPath;
|
||||||
|
}
|
||||||
|
// Try instance's fabricVersionId if available
|
||||||
|
String fabricId = instance.getFabricVersionId();
|
||||||
|
if (fabricId != null && !fabricId.isEmpty()) {
|
||||||
|
Path fabricPath = versionsDir.resolve(fabricId).resolve(fabricId + ".json");
|
||||||
|
if (Files.exists(fabricPath)) {
|
||||||
|
return fabricPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Try generic fabric pattern
|
||||||
|
try {
|
||||||
|
if (Files.exists(versionsDir)) {
|
||||||
|
try (var stream = Files.list(versionsDir)) {
|
||||||
|
return stream
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(dir -> dir.getFileName().toString().contains("fabric"))
|
||||||
|
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
|
||||||
|
.findFirst()
|
||||||
|
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
|
||||||
|
.filter(Files::exists)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("forge".equals(loaderType) || "neoforge".equals(loaderType)) {
|
||||||
|
String[] candidates = {
|
||||||
|
getVersionId(),
|
||||||
|
mcVersion + "-" + loaderType + "-" + loaderVersion,
|
||||||
|
loaderType + "-" + loaderVersion,
|
||||||
|
mcVersion + "-" + loaderVersion,
|
||||||
|
mcVersion
|
||||||
|
};
|
||||||
|
for (String candidate : candidates) {
|
||||||
|
Path jsonPath = versionsDir.resolve(candidate).resolve(candidate + ".json");
|
||||||
|
if (Files.exists(jsonPath)) {
|
||||||
|
return jsonPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Files.exists(versionsDir)) {
|
||||||
|
try (var stream = Files.list(versionsDir)) {
|
||||||
|
return stream
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||||
|
dir.getFileName().toString().contains("neoforge"))
|
||||||
|
.filter(dir -> dir.getFileName().toString().contains(mcVersion))
|
||||||
|
.findFirst()
|
||||||
|
.map(dir -> dir.resolve(dir.getFileName().toString() + ".json"))
|
||||||
|
.filter(Files::exists)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Path fallback = versionsDir.resolve(mcVersion).resolve(mcVersion + ".json");
|
||||||
|
if (Files.exists(fallback)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getVersionId() {
|
||||||
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
String mcVersion = instance.getMinecraftVersion();
|
||||||
|
String loaderVer = instance.getLoaderVersion();
|
||||||
|
|
||||||
|
if ("vanilla".equals(loaderType)) {
|
||||||
|
return mcVersion;
|
||||||
|
}
|
||||||
|
else if ("fabric".equals(loaderType)) {
|
||||||
|
String fabricId = instance.getFabricVersionId();
|
||||||
|
if (fabricId != null && !fabricId.isEmpty()) {
|
||||||
|
return fabricId;
|
||||||
|
}
|
||||||
|
return "fabric-loader-" + loaderVer + "-" + mcVersion;
|
||||||
|
}
|
||||||
|
else if ("forge".equals(loaderType)) {
|
||||||
|
return mcVersion + "-forge-" + loaderVer;
|
||||||
|
}
|
||||||
|
else if ("neoforge".equals(loaderType)) {
|
||||||
|
if (mcVersion.equals("1.20.1")) {
|
||||||
|
return mcVersion + "-neoforge-" + loaderVer;
|
||||||
|
}
|
||||||
|
return "neoforge-" + loaderVer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveMainClass(VersionManifest manifest) {
|
||||||
|
return manifest.getMainClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getVanillaMainClass() {
|
||||||
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
if ("fabric".equals(loaderType)) {
|
||||||
|
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
||||||
|
} else if ("forge".equals(loaderType)) {
|
||||||
|
return "net.minecraftforge.client.main.ForgeClient";
|
||||||
|
} else if ("neoforge".equals(loaderType)) {
|
||||||
|
return "cpw.mods.bootstraplauncher.BootstrapLauncher";
|
||||||
|
}
|
||||||
|
return "net.minecraft.client.main.Main";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveGameArguments(VersionManifest manifest, LaunchOptions options) {
|
||||||
|
List<String> args = new ArrayList<>();
|
||||||
|
Map<String, String> vars = buildVariableMap(options);
|
||||||
|
|
||||||
|
for (String raw : manifest.getGameArguments()) {
|
||||||
|
args.add(resolveVariable(raw, vars));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.getWidth() > 0) {
|
||||||
|
args.add("--width");
|
||||||
|
args.add(String.valueOf(options.getWidth()));
|
||||||
|
}
|
||||||
|
if (options.getHeight() > 0) {
|
||||||
|
args.add("--height");
|
||||||
|
args.add(String.valueOf(options.getHeight()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getVanillaGameArguments(LaunchOptions options) {
|
||||||
|
List<String> args = new ArrayList<>();
|
||||||
|
|
||||||
|
args.add("--version");
|
||||||
|
args.add(instance.getName());
|
||||||
|
args.add("--gameDir");
|
||||||
|
args.add(instance.getPath().toAbsolutePath().toString());
|
||||||
|
args.add("--assetsDir");
|
||||||
|
args.add(instance.getPath().resolve("assets").toAbsolutePath().toString());
|
||||||
|
args.add("--assetIndex");
|
||||||
|
String assetIndex = instance.getAssetIndex();
|
||||||
|
if (assetIndex == null || assetIndex.isEmpty()) {
|
||||||
|
assetIndex = instance.getMinecraftVersion();
|
||||||
|
System.out.println(ZAnsi.yellow("Asset index не найден, использую версию: " + assetIndex));
|
||||||
|
} else {
|
||||||
|
System.out.println(ZAnsi.green("Использую asset index: " + assetIndex));
|
||||||
|
}
|
||||||
|
args.add(assetIndex);
|
||||||
|
args.add("--username");
|
||||||
|
args.add(options.getUsername() != null ? options.getUsername() : "Player");
|
||||||
|
args.add("--accessToken");
|
||||||
|
args.add(options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||||
|
args.add("--uuid");
|
||||||
|
args.add(options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||||
|
args.add("--userType");
|
||||||
|
args.add("legacy");
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> buildVariableMap(LaunchOptions options) {
|
||||||
|
Map<String, String> vars = new HashMap<>();
|
||||||
|
|
||||||
|
Path gameDir = instance.getPath().toAbsolutePath();
|
||||||
|
Path assetsDir = gameDir.resolve("assets");
|
||||||
|
Path nativesDir = gameDir.resolve("natives");
|
||||||
|
Path librariesDir = gameDir.resolve("libraries");
|
||||||
|
|
||||||
|
vars.put("version_name", instance.getName());
|
||||||
|
vars.put("game_directory", gameDir.toString());
|
||||||
|
vars.put("assets_root", assetsDir.toString());
|
||||||
|
vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion());
|
||||||
|
vars.put("auth_player_name", options.getUsername() != null ? options.getUsername() : "Player");
|
||||||
|
vars.put("auth_access_token", options.getAccessToken() != null ? options.getAccessToken() : "0");
|
||||||
|
vars.put("auth_uuid", options.getUuid() != null ? options.getUuid() : "00000000-0000-0000-0000-000000000000");
|
||||||
|
vars.put("auth_xuid", "0");
|
||||||
|
vars.put("user_type", "legacy");
|
||||||
|
vars.put("version_type", "release");
|
||||||
|
vars.put("natives_directory", nativesDir.toString());
|
||||||
|
vars.put("library_directory", librariesDir.toString());
|
||||||
|
vars.put("launcher_name", "ZernMC");
|
||||||
|
vars.put("launcher_version", "1.0");
|
||||||
|
vars.put("classpath_separator", System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":");
|
||||||
|
vars.put("resolution_width", String.valueOf(options.getWidth() > 0 ? options.getWidth() : 1920));
|
||||||
|
vars.put("resolution_height", String.valueOf(options.getHeight() > 0 ? options.getHeight() : 1080));
|
||||||
|
vars.put("game_directory", gameDir.toString());
|
||||||
|
|
||||||
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
if ("forge".equals(loaderType)) {
|
||||||
|
vars.put("forge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||||
|
} else if ("neoforge".equals(loaderType)) {
|
||||||
|
vars.put("neoforge_version", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||||
|
vars.put("fml.neoForgeVersion", instance.getLoaderVersion() != null ? instance.getLoaderVersion() : "");
|
||||||
|
vars.put("fml.neoForgeGroup", "net.neoforged");
|
||||||
|
}
|
||||||
|
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveVariable(String raw, Map<String, String> vars) {
|
||||||
|
if (!raw.contains("${")) return raw;
|
||||||
|
String result = raw;
|
||||||
|
for (Map.Entry<String, String> entry : vars.entrySet()) {
|
||||||
|
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildClasspathFromManifest(VersionManifest manifest) throws Exception {
|
||||||
|
List<String> paths = new ArrayList<>();
|
||||||
|
Path librariesDir = instance.getPath().resolve("libraries");
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + manifest.getLibraries().size() + " libraries in manifest"));
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(ZAnsi.cyan(" buildClasspathFromManifest: " + paths.size() + " libraries in classpath"));
|
||||||
|
|
||||||
|
Path versionJar = findVersionJar();
|
||||||
|
if (versionJar != null) {
|
||||||
|
paths.add(0, versionJar.toAbsolutePath().toString());
|
||||||
|
System.out.println(ZAnsi.green(" Added version jar: " + versionJar.getFileName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||||
|
return String.join(separator, paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildVanillaClasspath() throws Exception {
|
||||||
|
List<String> paths = new ArrayList<>();
|
||||||
|
String versionId = getVersionId();
|
||||||
|
Path versionJar = instance.getPath()
|
||||||
|
.resolve("versions")
|
||||||
|
.resolve(versionId)
|
||||||
|
.resolve(versionId + ".jar");
|
||||||
|
|
||||||
|
if (Files.exists(versionJar)) {
|
||||||
|
paths.add(versionJar.toAbsolutePath().toString());
|
||||||
|
} else {
|
||||||
|
String mcVersion = instance.getMinecraftVersion();
|
||||||
|
Path fallbackJar = instance.getPath()
|
||||||
|
.resolve("versions")
|
||||||
|
.resolve(mcVersion)
|
||||||
|
.resolve(mcVersion + ".jar");
|
||||||
|
if (Files.exists(fallbackJar)) {
|
||||||
|
paths.add(fallbackJar.toAbsolutePath().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Path librariesDir = instance.getPath().resolve("libraries");
|
||||||
|
if (Files.exists(librariesDir)) {
|
||||||
|
try (var stream = Files.walk(librariesDir)) {
|
||||||
|
stream.filter(p -> p.toString().endsWith(".jar"))
|
||||||
|
.map(p -> p.toAbsolutePath().toString())
|
||||||
|
.forEach(paths::add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
||||||
|
return String.join(separator, paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Path findVersionJar() {
|
||||||
|
String versionId = getVersionId();
|
||||||
|
Path versionsDir = instance.getPath().resolve("versions");
|
||||||
|
|
||||||
|
Path[] candidates = {
|
||||||
|
versionsDir.resolve(versionId).resolve(versionId + ".jar"),
|
||||||
|
versionsDir.resolve(instance.getMinecraftVersion()).resolve(instance.getMinecraftVersion() + ".jar")
|
||||||
|
};
|
||||||
|
|
||||||
|
for (Path candidate : candidates) {
|
||||||
|
if (Files.exists(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Files.exists(versionsDir)) {
|
||||||
|
try (var stream = Files.list(versionsDir)) {
|
||||||
|
return stream
|
||||||
|
.filter(Files::isDirectory)
|
||||||
|
.filter(dir -> dir.getFileName().toString().contains("forge") ||
|
||||||
|
dir.getFileName().toString().contains("neoforge"))
|
||||||
|
.filter(dir -> dir.getFileName().toString().contains(instance.getMinecraftVersion()))
|
||||||
|
.findFirst()
|
||||||
|
.map(dir -> dir.resolve(dir.getFileName().toString() + ".jar"))
|
||||||
|
.filter(Files::exists)
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String mavenToPath(String mavenName) {
|
||||||
|
String[] parts = mavenName.split(":");
|
||||||
|
if (parts.length < 3) return mavenName;
|
||||||
|
|
||||||
|
String group = parts[0].replace('.', '/');
|
||||||
|
String artifact = parts[1];
|
||||||
|
String version = parts[2];
|
||||||
|
|
||||||
|
if (parts.length == 4) {
|
||||||
|
String classifier = parts[3];
|
||||||
|
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + "-" + classifier + ".jar";
|
||||||
|
}
|
||||||
|
|
||||||
|
return group + "/" + artifact + "/" + version + "/" + artifact + "-" + version + ".jar";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> getJvmArguments(LaunchOptions options) {
|
||||||
|
List<String> jvmArgs = new ArrayList<>();
|
||||||
|
|
||||||
|
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
||||||
|
jvmArgs.add("-Xmx" + ramMB + "M");
|
||||||
|
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
||||||
|
|
||||||
|
jvmArgs.add("-XX:+UseG1GC");
|
||||||
|
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
||||||
|
jvmArgs.add("-XX:G1NewSizePercent=20");
|
||||||
|
jvmArgs.add("-XX:G1ReservePercent=20");
|
||||||
|
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
||||||
|
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
||||||
|
|
||||||
|
String loaderType = instance.getLoaderType().toLowerCase();
|
||||||
|
|
||||||
|
if ("fabric".equals(loaderType)) {
|
||||||
|
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||||
|
} else if ("forge".equals(loaderType)) {
|
||||||
|
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||||
|
} else if ("neoforge".equals(loaderType)) {
|
||||||
|
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
||||||
|
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
||||||
|
jvmArgs.addAll(options.getExtraJvmArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
return jvmArgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
+174
@@ -0,0 +1,174 @@
|
|||||||
|
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("has_custom_resolution")) {
|
||||||
|
continue; // Лаунчер сам обрабатывает разрешение
|
||||||
|
}
|
||||||
|
if (key.startsWith("is_demo_user")) {
|
||||||
|
// Лаунчер не использует demo режим, считаем фичу false
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Неизвестная фича — считаем false
|
||||||
|
matches = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-5
@@ -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();
|
||||||
|
if (next == 91) { // '[' — start of arrow escape sequence
|
||||||
|
int arrow = terminal.reader().read();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.ui.jfx;
|
||||||
|
|
||||||
|
import javafx.application.Application;
|
||||||
|
import javafx.scene.Scene;
|
||||||
|
import javafx.scene.web.WebView;
|
||||||
|
import javafx.scene.web.WebEngine;
|
||||||
|
import javafx.stage.Stage;
|
||||||
|
import javafx.concurrent.Worker;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import me.sashegdev.zernmc.launcher.api.LauncherAPI;
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
||||||
|
import me.sashegdev.zernmc.launcher.minecraft.InstanceManager;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.jar.JarEntry;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.Headers;
|
||||||
|
|
||||||
|
public class JFXLauncher extends Application {
|
||||||
|
private static final int PORT = 8080;
|
||||||
|
private static final String APP_TITLE = "ZernMC Launcher";
|
||||||
|
private static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "http://87.120.187.36:1582");
|
||||||
|
private final LauncherAPI api = new LauncherAPI();
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
private HttpServer server;
|
||||||
|
private StringBuilder logBuffer = new StringBuilder();
|
||||||
|
private static StringBuilder gameLogBuffer = new StringBuilder();
|
||||||
|
private static Path gameLogFile;
|
||||||
|
private Stage mainStage;
|
||||||
|
|
||||||
|
public static void appendGameLog(String log) {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.append(log).append("\n");
|
||||||
|
|
||||||
|
if (gameLogFile != null) {
|
||||||
|
try {
|
||||||
|
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||||
|
Files.writeString(gameLogFile, "[" + timestamp + "] " + log + "\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void initGameLog(Path instanceDir) {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
gameLogBuffer.setLength(0);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Path logsDir = instanceDir.resolve("logs");
|
||||||
|
Files.createDirectories(logsDir);
|
||||||
|
gameLogFile = logsDir.resolve("game.log");
|
||||||
|
Files.writeString(gameLogFile, "=== Game Log " + LocalDateTime.now() + " ===\n",
|
||||||
|
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getGameLogs() {
|
||||||
|
synchronized (gameLogBuffer) {
|
||||||
|
return gameLogBuffer.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
launch(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void extractAssets() {
|
||||||
|
try {
|
||||||
|
Path assetsDir = Paths.get("assets");
|
||||||
|
if (Files.exists(assetsDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String serverVersion = getServerVersion();
|
||||||
|
if (serverVersion != null && !serverVersion.isEmpty()) {
|
||||||
|
System.out.println("[JFX] Загрузка assets через мета для версии " + serverVersion);
|
||||||
|
if (downloadAssetsFromMeta(serverVersion)) {
|
||||||
|
System.out.println("[JFX] Assets загружены через мета");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
System.out.println("[JFX] Мета недоступна, использую fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("[JFX] Извлечение assets из JAR...");
|
||||||
|
Path jarPath = Paths.get(JFXLauncher.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||||
|
if (Files.exists(jarPath) && jarPath.toString().endsWith(".jar")) {
|
||||||
|
try (JarFile jar = new JarFile(jarPath.toFile())) {
|
||||||
|
var entries = jar.entries();
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
JarEntry entry = entries.nextElement();
|
||||||
|
if (entry.getName().startsWith("assets/")) {
|
||||||
|
Path outPath = assetsDir.resolve(entry.getName().substring(7));
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
Files.createDirectories(outPath);
|
||||||
|
} else {
|
||||||
|
Files.createDirectories(outPath.getParent());
|
||||||
|
try (InputStream is = jar.getInputStream(entry)) {
|
||||||
|
Files.copy(is, outPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("[JFX] Assets извлечены из JAR");
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[JFX] Ошибка извлечения assets: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getServerVersion() {
|
||||||
|
try {
|
||||||
|
URL url = new URL(LAUNCHER_SERVER + "/launcher/version");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setConnectTimeout(3000);
|
||||||
|
conn.setReadTimeout(3000);
|
||||||
|
if (conn.getResponseCode() == 200) {
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
|
String response = sb.toString();
|
||||||
|
int versionStart = response.indexOf("\"version\":\"");
|
||||||
|
if (versionStart >= 0) {
|
||||||
|
int afterVersion = versionStart + 11;
|
||||||
|
int versionEnd = response.indexOf("\"", afterVersion);
|
||||||
|
if (versionEnd > afterVersion) {
|
||||||
|
return response.substring(afterVersion, versionEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean downloadAssetsFromMeta(String version) {
|
||||||
|
try {
|
||||||
|
URL metaUrl = new URL(LAUNCHER_SERVER + "/launcher/meta/" + version);
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) metaUrl.openConnection();
|
||||||
|
conn.setConnectTimeout(5000);
|
||||||
|
conn.setReadTimeout(10000);
|
||||||
|
if (conn.getResponseCode() != 200) return false;
|
||||||
|
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
|
org.json.JSONObject meta = new org.json.JSONObject(sb.toString());
|
||||||
|
|
||||||
|
Path assetsDir = Paths.get("assets");
|
||||||
|
Files.createDirectories(assetsDir);
|
||||||
|
|
||||||
|
for (Object fileObj : meta.getJSONArray("files")) {
|
||||||
|
org.json.JSONObject file = (org.json.JSONObject) fileObj;
|
||||||
|
String path = file.getString("path");
|
||||||
|
if (path.startsWith("assets/")) {
|
||||||
|
String downloadUrl = LAUNCHER_SERVER + "/launcher/file/" + version + "/" + path;
|
||||||
|
Path outPath = assetsDir.resolve(path.substring(7));
|
||||||
|
Files.createDirectories(outPath.getParent());
|
||||||
|
|
||||||
|
URL fileUrl = new URL(downloadUrl);
|
||||||
|
HttpURLConnection fileConn = (HttpURLConnection) fileUrl.openConnection();
|
||||||
|
fileConn.setConnectTimeout(10000);
|
||||||
|
fileConn.setReadTimeout(30000);
|
||||||
|
if (fileConn.getResponseCode() == 200) {
|
||||||
|
try (InputStream is = fileConn.getInputStream()) {
|
||||||
|
Files.copy(is, outPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileConn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("[JFX] Ошибка загрузки через мета: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(Stage stage) {
|
||||||
|
this.mainStage = stage;
|
||||||
|
|
||||||
|
try {
|
||||||
|
extractAssets();
|
||||||
|
log("Запуск JFX UI...");
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
WebView webView = new WebView();
|
||||||
|
WebEngine engine = webView.getEngine();
|
||||||
|
engine.setJavaScriptEnabled(true);
|
||||||
|
|
||||||
|
engine.getLoadWorker().stateProperty().addListener((obs, oldState, newState) -> {
|
||||||
|
log("[UI] Load state: " + oldState + " -> " + newState);
|
||||||
|
if (newState == Worker.State.SUCCEEDED) {
|
||||||
|
log("Страница загружена");
|
||||||
|
} else if (newState == Worker.State.FAILED) {
|
||||||
|
log("[UI] Load FAILED: " + engine.getLoadWorker().getException());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.setOnAlert(e -> log("[UI] Alert: " + e.getData()));
|
||||||
|
|
||||||
|
String url = "http://localhost:" + PORT + "/assets/ui/index.html";
|
||||||
|
engine.load(url);
|
||||||
|
|
||||||
|
stage.setTitle(APP_TITLE);
|
||||||
|
stage.setWidth(1200);
|
||||||
|
stage.setHeight(800);
|
||||||
|
stage.setScene(new Scene(webView));
|
||||||
|
stage.show();
|
||||||
|
|
||||||
|
log("Окно отображено");
|
||||||
|
|
||||||
|
stage.setOnCloseRequest(e -> {
|
||||||
|
log("Закрытие...");
|
||||||
|
stopServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("Ошибка: " + e.getMessage());
|
||||||
|
e.printStackTrace();
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startServer() throws Exception {
|
||||||
|
server = HttpServer.create(new InetSocketAddress("localhost", PORT), 0);
|
||||||
|
|
||||||
|
server.createContext("/api/login", this::handleLogin);
|
||||||
|
server.createContext("/api/account", this::handleAccount);
|
||||||
|
server.createContext("/api/instances", this::handleInstances);
|
||||||
|
server.createContext("/api/launch", this::handleLaunch);
|
||||||
|
server.createContext("/api/install", this::handleInstall);
|
||||||
|
server.createContext("/api/logs", this::handleLogs);
|
||||||
|
server.createContext("/api/game-logs", this::handleGameLogs);
|
||||||
|
server.createContext("/api/exit", this::handleExit);
|
||||||
|
server.createContext("/assets/", this::handleStatic);
|
||||||
|
|
||||||
|
server.setExecutor(Executors.newCachedThreadPool());
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
log("HTTP сервер на порту " + PORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopServer() {
|
||||||
|
if (server != null) server.stop(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLogin(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!"POST".equals(exchange.getRequestMethod())) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Метод не поддерживается"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String username = body.get("username");
|
||||||
|
String password = body.get("password");
|
||||||
|
|
||||||
|
var result = api.login(username, password);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("username", result.getData().getUsername());
|
||||||
|
data.put("token", result.getData().getToken());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
log("Вход: " + username);
|
||||||
|
} else {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleAccount(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("username", api.getCurrentUsername());
|
||||||
|
data.put("passActive", AuthManager.hasActivePass());
|
||||||
|
data.put("role", AuthManager.getRole());
|
||||||
|
data.put("roleName", AuthManager.getRoleName());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleInstances(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
var result = api.getAllInstances();
|
||||||
|
Map<String, Object> response = new HashMap<>();
|
||||||
|
response.put("success", result.isSuccess());
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
response.put("data", result.getData());
|
||||||
|
} else {
|
||||||
|
response.put("error", result.getError());
|
||||||
|
}
|
||||||
|
sendJson(exchange, response);
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLaunch(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String name = body.get("name");
|
||||||
|
|
||||||
|
var result = api.launch(name);
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("pid", result.getData().getPid());
|
||||||
|
data.put("status", result.getData().getStatus());
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", data));
|
||||||
|
log("Запущено: " + name);
|
||||||
|
} else {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", result.getError()));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleInstall(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!api.isLoggedIn()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", "Не авторизован"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = parseJson(exchange.getRequestBody());
|
||||||
|
String name = body.get("name");
|
||||||
|
String version = body.get("version");
|
||||||
|
String loader = body.get("loader");
|
||||||
|
|
||||||
|
log("Установка: " + name + " " + version + " " + loader);
|
||||||
|
|
||||||
|
var createResult = api.instances().createInstance(name);
|
||||||
|
if (!createResult.isSuccess()) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", createResult.getError()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Instance instance = InstanceManager.getInstance(name);
|
||||||
|
if (instance != null) {
|
||||||
|
instance.setMinecraftVersion(version);
|
||||||
|
instance.setLoaderType(loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", true));
|
||||||
|
log("Установлено: " + name);
|
||||||
|
} catch (Exception e) {
|
||||||
|
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLogs(HttpExchange exchange) {
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", logBuffer.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleGameLogs(HttpExchange exchange) {
|
||||||
|
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleExit(HttpExchange exchange) {
|
||||||
|
log("Выход...");
|
||||||
|
if (mainStage != null) mainStage.close();
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleStatic(HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
String path = exchange.getRequestURI().getPath();
|
||||||
|
log("[UI] Request: " + path);
|
||||||
|
|
||||||
|
String relativePath = path.startsWith("/") ? path.substring(1) : path;
|
||||||
|
Path file = Paths.get(relativePath).toAbsolutePath();
|
||||||
|
|
||||||
|
if (!Files.exists(file)) {
|
||||||
|
log("[UI] File not found: " + file);
|
||||||
|
exchange.sendResponseHeaders(404, 0);
|
||||||
|
exchange.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] content = Files.readAllBytes(file);
|
||||||
|
log("[UI] Loaded " + content.length + " bytes: " + path);
|
||||||
|
String ct = getContentType(path);
|
||||||
|
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", ct);
|
||||||
|
exchange.sendResponseHeaders(200, content.length);
|
||||||
|
exchange.getResponseBody().write(content);
|
||||||
|
exchange.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log("[UI] Error serving: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getContentType(String path) {
|
||||||
|
if (path.endsWith(".html")) return "text/html; charset=utf-8";
|
||||||
|
if (path.endsWith(".css")) return "text/css; charset=utf-8";
|
||||||
|
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
|
||||||
|
return "text/plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, String> parseJson(InputStream body) {
|
||||||
|
try {
|
||||||
|
return gson.fromJson(new String(body.readAllBytes(), StandardCharsets.UTF_8), Map.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendJson(HttpExchange exchange, Map<String, Object> response) {
|
||||||
|
try {
|
||||||
|
String json = gson.toJson(response);
|
||||||
|
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
exchange.getResponseBody().write(bytes);
|
||||||
|
exchange.close();
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void log(String msg) {
|
||||||
|
String entry = "[" + java.time.LocalTime.now() + "] " + msg + "\n";
|
||||||
|
logBuffer.append(entry);
|
||||||
|
System.out.println("[JFX] " + msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -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;
|
||||||
+15
@@ -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) {
|
||||||
|
// Игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+22
-4
@@ -34,8 +34,9 @@ public class Version {
|
|||||||
public static boolean isNewer(String current, String server) {
|
public static boolean isNewer(String current, String server) {
|
||||||
if (current == null || server == null) return false;
|
if (current == null || server == null) return false;
|
||||||
|
|
||||||
current = current.replace("-SNAPSHOT", "").trim();
|
// Нормализуем версии - убираем суффиксы типа -any, -alpha, -beta, -SNAPSHOT
|
||||||
server = server.replace("-SNAPSHOT", "").trim();
|
current = normalizeVersion(current);
|
||||||
|
server = normalizeVersion(server);
|
||||||
|
|
||||||
if (current.equals(server)) return false;
|
if (current.equals(server)) return false;
|
||||||
|
|
||||||
@@ -45,12 +46,29 @@ public class Version {
|
|||||||
int max = Math.max(cParts.length, sParts.length);
|
int max = Math.max(cParts.length, sParts.length);
|
||||||
|
|
||||||
for (int i = 0; i < max; i++) {
|
for (int i = 0; i < max; i++) {
|
||||||
int c = i < cParts.length ? Integer.parseInt(cParts[i]) : 0;
|
int c = i < cParts.length ? parseVersionPart(cParts[i]) : 0;
|
||||||
int s = i < sParts.length ? Integer.parseInt(sParts[i]) : 0;
|
int s = i < sParts.length ? parseVersionPart(sParts[i]) : 0;
|
||||||
|
|
||||||
if (s > c) return true;
|
if (s > c) return true;
|
||||||
if (s < c) return false;
|
if (s < c) return false;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeVersion(String version) {
|
||||||
|
if (version == null) return "0.0.0";
|
||||||
|
|
||||||
|
// Убираем суффиксы: -any, -alpha1, -beta2, -SNAPSHOT, -rc1 и т.д.
|
||||||
|
return version.split("-")[0].split("\\+")[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parseVersionPart(String part) {
|
||||||
|
try {
|
||||||
|
// Убираем всё, что не является цифрой (на случай если суффикс остался)
|
||||||
|
String numeric = part.replaceAll("[^0-9]", "");
|
||||||
|
return numeric.isEmpty() ? 0 : Integer.parseInt(numeric);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package me.sashegdev.zernmc.launcher.utils;
|
||||||
|
|
||||||
|
import org.fusesource.jansi.Ansi;
|
||||||
|
import org.fusesource.jansi.AnsiConsole;
|
||||||
|
|
||||||
|
public class ZAnsi {
|
||||||
|
|
||||||
|
//поддержка ANSI епта
|
||||||
|
public static void install() {
|
||||||
|
AnsiConsole.systemInstall();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void uninstall() {
|
||||||
|
AnsiConsole.systemUninstall();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Основные цвета ===
|
||||||
|
public static String green(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightGreen(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String cyan(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightCyan(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String yellow(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightYellow(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String red(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightRed(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String blue(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightBlue(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String magenta(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightMagenta(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пурпурный как brightPurple (используем magenta)
|
||||||
|
public static String purple(String text) {
|
||||||
|
return brightMagenta(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightPurple(String text) {
|
||||||
|
return brightMagenta(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String white(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightWhite(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String black(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Фоновые цвета ===
|
||||||
|
public static String bgGreen(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bgRed(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bgYellow(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bgBlue(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Стили ===
|
||||||
|
public static String bold(String text) {
|
||||||
|
return Ansi.ansi().bold().a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String reset() {
|
||||||
|
return Ansi.ansi().reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Комбинированные удобные методы ===
|
||||||
|
public static String header(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String success(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String error(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String warning(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String info(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String selected(String text) {
|
||||||
|
return Ansi.ansi()
|
||||||
|
.bgBright(Ansi.Color.WHITE)
|
||||||
|
.fg(Ansi.Color.BLACK)
|
||||||
|
.bold()
|
||||||
|
.a(" > " + text + " ")
|
||||||
|
.reset()
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String dim(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Цветной текст для ролей ===
|
||||||
|
public static String roleUser(String text) {
|
||||||
|
return white(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String rolePassHolder(String text) {
|
||||||
|
return brightGreen(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleModerator(String text) {
|
||||||
|
return brightBlue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleElder(String text) {
|
||||||
|
return brightPurple(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleCreator(String text) {
|
||||||
|
return brightRed(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Очистка экрана ===
|
||||||
|
public static String clearScreen() {
|
||||||
|
return Ansi.ansi().eraseScreen().cursor(1, 1).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Прогресс бар символы ===
|
||||||
|
public static String progressChar() {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String progressEmpty() {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-13
@@ -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,13 +424,19 @@ 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) {
|
||||||
@@ -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,100 @@
|
|||||||
|
<!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="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- Экран логина -->
|
||||||
|
<div id="login-screen" class="screen">
|
||||||
|
<div class="login-container">
|
||||||
|
<h1 class="logo">ZernMC</h1>
|
||||||
|
<p class="subtitle">Private Launcher</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<input type="text" id="username" placeholder="Никнейм" required>
|
||||||
|
<input type="password" id="password" placeholder="Пароль" required>
|
||||||
|
<button type="submit" class="btn-primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="error hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Главное меню -->
|
||||||
|
<div id="main-screen" class="screen hidden">
|
||||||
|
<!-- Хедер -->
|
||||||
|
<header class="header">
|
||||||
|
<h1 class="logo">ZernMC Launcher</h1>
|
||||||
|
<div class="account-info">
|
||||||
|
<span id="account-name">-</span>
|
||||||
|
<span id="account-status" class="badge">-</span>
|
||||||
|
<span id="account-role" class="badge role-badge">-</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Слева: выбор сборки -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<h2>Сборки</h2>
|
||||||
|
<div id="instances-list" class="instances-container">
|
||||||
|
<!-- Динамически заполняется через JS -->
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- По центру: логи -->
|
||||||
|
<section class="logs-panel">
|
||||||
|
<h2>Логи</h2>
|
||||||
|
<div id="logs-container"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Низ: управление -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="instance-info">
|
||||||
|
<span id="selected-name">-</span>
|
||||||
|
<span id="selected-version">-</span>
|
||||||
|
<span id="selected-loader">-</span>
|
||||||
|
</div>
|
||||||
|
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно установки -->
|
||||||
|
<div id="install-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Установка сборки</h2>
|
||||||
|
<form id="install-form">
|
||||||
|
<label>Версия Minecraft
|
||||||
|
<select id="install-mc-version">
|
||||||
|
<option value="1.20.4">1.20.4</option>
|
||||||
|
<option value="1.20.2">1.20.2</option>
|
||||||
|
<option value="1.20.1">1.20.1</option>
|
||||||
|
<option value="1.19.2">1.19.2</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Загрузчик
|
||||||
|
<select id="install-loader">
|
||||||
|
<option value="vanilla">Vanilla</option>
|
||||||
|
<option value="fabric">Fabric</option>
|
||||||
|
<option value="forge">Forge</option>
|
||||||
|
<option value="neoforge">NeoForge</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Имя сборки
|
||||||
|
<input type="text" id="install-name" placeholder="MyServer" required>
|
||||||
|
</label>
|
||||||
|
<div class="modal-buttons">
|
||||||
|
<button type="button" class="btn-secondary" onclick="closeInstallModal()">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary">Установить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="launcher.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
const API_BASE = 'http://localhost:8080/api';
|
||||||
|
|
||||||
|
let state = {
|
||||||
|
loggedIn: false,
|
||||||
|
account: null,
|
||||||
|
instances: [],
|
||||||
|
selectedInstance: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============ API ============
|
||||||
|
|
||||||
|
async function apiCall(endpoint, options = {}) {
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
const config = {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, config);
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
log('Ошибка соединения с сервером: ' + e.message, 'error');
|
||||||
|
return { success: false, error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Login ============
|
||||||
|
|
||||||
|
async function login(username, password) {
|
||||||
|
log('Выполняется вход...', 'info');
|
||||||
|
const result = await apiCall('/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
state.loggedIn = true;
|
||||||
|
state.account = result.data;
|
||||||
|
log('Вход выполнен: ' + result.data.username, 'success');
|
||||||
|
showMainScreen();
|
||||||
|
await loadInstances();
|
||||||
|
} else {
|
||||||
|
log('Ошибка входа: ' + result.error, 'error');
|
||||||
|
showError(result.error);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
const el = document.getElementById('login-error');
|
||||||
|
el.textContent = message;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
document.getElementById('login-error').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Account ============
|
||||||
|
|
||||||
|
async function loadAccountInfo() {
|
||||||
|
const result = await apiCall('/account');
|
||||||
|
if (result.success) {
|
||||||
|
state.account = result.data;
|
||||||
|
state.loggedIn = true;
|
||||||
|
document.getElementById('account-name').textContent = result.data.username;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('account-status');
|
||||||
|
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
|
||||||
|
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
|
||||||
|
|
||||||
|
const roleEl = document.getElementById('account-role');
|
||||||
|
if (roleEl && result.data.roleName) {
|
||||||
|
roleEl.textContent = result.data.roleName;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showLoginScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Instances ============
|
||||||
|
|
||||||
|
async function loadInstances() {
|
||||||
|
log('Загрузка списка сборок...', 'info');
|
||||||
|
const result = await apiCall('/instances');
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
state.instances = result.data;
|
||||||
|
renderInstances();
|
||||||
|
log('Загружено ' + result.data.length + ' сборок', 'success');
|
||||||
|
} else {
|
||||||
|
log('Ошибка загрузки: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInstances() {
|
||||||
|
const container = document.getElementById('instances-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
state.instances.forEach(inst => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'instance-card';
|
||||||
|
card.dataset.name = inst.name;
|
||||||
|
card.onclick = () => selectInstance(inst.name);
|
||||||
|
|
||||||
|
let details = `
|
||||||
|
<span class="instance-version">${inst.version || '?'}</span>
|
||||||
|
<span class="instance-loader">${inst.loaderType || 'vanilla'}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (inst.isServerPack) {
|
||||||
|
details += `<span class="instance-server-version">v${inst.serverVersion}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="instance-name">${inst.name}</div>
|
||||||
|
<div class="instance-details">${details}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectInstance(name) {
|
||||||
|
state.selectedInstance = state.instances.find(i => i.name === name);
|
||||||
|
|
||||||
|
document.querySelectorAll('.instance-card').forEach(c => {
|
||||||
|
c.classList.toggle('selected', c.dataset.name === name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = document.getElementById('play-btn');
|
||||||
|
const inst = state.selectedInstance;
|
||||||
|
|
||||||
|
if (inst) {
|
||||||
|
document.getElementById('selected-name').textContent = inst.name;
|
||||||
|
document.getElementById('selected-version').textContent = inst.version || '-';
|
||||||
|
document.getElementById('selected-loader').textContent = inst.loaderType || 'vanilla';
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Играть';
|
||||||
|
btn.classList.remove('update');
|
||||||
|
} else {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Выберите сборку';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Launch ============
|
||||||
|
|
||||||
|
async function launchInstance() {
|
||||||
|
if (!state.selectedInstance) return;
|
||||||
|
|
||||||
|
const name = state.selectedInstance.name;
|
||||||
|
log('Запуск сборки: ' + name, 'info');
|
||||||
|
|
||||||
|
const result = await apiCall('/launch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log('Сборка запущена! PID: ' + result.data.pid, 'success');
|
||||||
|
} else {
|
||||||
|
log('Ошибка запуска: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Install ============
|
||||||
|
|
||||||
|
function openInstallModal() {
|
||||||
|
document.getElementById('install-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeInstallModal() {
|
||||||
|
document.getElementById('install-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installInstance(formData) {
|
||||||
|
log('Установка сборки...', 'info');
|
||||||
|
const result = await apiCall('/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
log('Сборка установлена!', 'success');
|
||||||
|
closeInstallModal();
|
||||||
|
await loadInstances();
|
||||||
|
} else {
|
||||||
|
log('Ошибка установки: ' + result.error, 'error');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Logs ============
|
||||||
|
|
||||||
|
function log(message, type = 'info') {
|
||||||
|
const container = document.getElementById('logs-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'log-line ' + type;
|
||||||
|
line.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
|
||||||
|
container.appendChild(line);
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogs() {
|
||||||
|
document.getElementById('logs-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Screens ============
|
||||||
|
|
||||||
|
function showLoginScreen() {
|
||||||
|
document.getElementById('login-screen').classList.remove('hidden');
|
||||||
|
document.getElementById('main-screen').classList.add('hidden');
|
||||||
|
clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMainScreen() {
|
||||||
|
document.getElementById('login-screen').classList.add('hidden');
|
||||||
|
document.getElementById('main-screen').classList.remove('hidden');
|
||||||
|
|
||||||
|
if (state.account) {
|
||||||
|
document.getElementById('account-name').textContent = state.account.username;
|
||||||
|
const statusEl = document.getElementById('account-status');
|
||||||
|
statusEl.textContent = state.account.passActive ? 'PRO' : 'FREE';
|
||||||
|
statusEl.className = 'badge ' + (state.account.passActive ? 'active' : 'inactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Init ============
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
log('Запуск лаунчера...', 'info');
|
||||||
|
|
||||||
|
await loadAccountInfo();
|
||||||
|
|
||||||
|
if (!state.loggedIn) {
|
||||||
|
showLoginScreen();
|
||||||
|
} else {
|
||||||
|
showMainScreen();
|
||||||
|
await loadInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling for server logs
|
||||||
|
startLogPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastLogLength = 0;
|
||||||
|
let lastGameLogLength = 0;
|
||||||
|
function startLogPolling() {
|
||||||
|
setInterval(async () => {
|
||||||
|
// Launcher logs
|
||||||
|
const result = await apiCall('/logs');
|
||||||
|
if (result.success && result.data && result.data.length > lastLogLength) {
|
||||||
|
const newLogs = result.data.substring(lastLogLength);
|
||||||
|
const lines = newLogs.split('\n').filter(l => l.trim());
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (line.includes('[JFX]')) {
|
||||||
|
log(line.replace('[JFX] ', ''), 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
lastLogLength = result.data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game logs
|
||||||
|
const gameResult = await apiCall('/game-logs');
|
||||||
|
if (gameResult.success && gameResult.data && gameResult.data.length > lastGameLogLength) {
|
||||||
|
const newLogs = gameResult.data.substring(lastGameLogLength);
|
||||||
|
const lines = newLogs.split('\n').filter(l => l.trim());
|
||||||
|
lines.forEach(line => {
|
||||||
|
log('[GAME] ' + line, 'info');
|
||||||
|
});
|
||||||
|
lastGameLogLength = gameResult.data.length;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Form Handlers ============
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
await login(username, password);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('play-btn').addEventListener('click', async () => {
|
||||||
|
await launchInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('install-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
name: document.getElementById('install-name').value,
|
||||||
|
version: document.getElementById('install-mc-version').value,
|
||||||
|
loader: document.getElementById('install-loader').value
|
||||||
|
};
|
||||||
|
|
||||||
|
await installInstance(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose functions globally for inline handlers
|
||||||
|
window.closeInstallModal = closeInstallModal;
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #0f3460;
|
||||||
|
--accent: #e94560;
|
||||||
|
--accent-hover: #ff6b6b;
|
||||||
|
--text-primary: #eaeaea;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--success: #4ade80;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--error: #ef4444;
|
||||||
|
--border: #2d2d4a;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screens */
|
||||||
|
.screen {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Screen */
|
||||||
|
#login-screen {
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 25px 50px var(--shadow);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Screen */
|
||||||
|
#main-screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#account-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.active {
|
||||||
|
background: rgba(74, 222, 128, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.inactive {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar h2 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instances-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(233, 69, 96, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-name {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-version, .instance-loader {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-server-version {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(251, 191, 36, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logs Panel */
|
||||||
|
.logs-panel {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-panel h2 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#logs-container {
|
||||||
|
flex: 1;
|
||||||
|
background: #0d0d1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line.info { color: var(--text-primary); }
|
||||||
|
.log-line.success { color: var(--success); }
|
||||||
|
.log-line.warning { color: var(--warning); }
|
||||||
|
.log-line.error { color: var(--error); }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instance-info span {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selected-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play {
|
||||||
|
background: var(--success);
|
||||||
|
color: #0a0a0a;
|
||||||
|
border: none;
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 20px rgba(74, 222, 128, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play:disabled {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-play.update {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#install-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#install-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#install-form select, #install-form input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
+90
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-109
@@ -6,56 +6,110 @@
|
|||||||
<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.9</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
|
<name>ZernMC Launcher Parent</name>
|
||||||
|
<description>ZernMC Launcher - Multi-module project</description>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>bootstrap</module>
|
||||||
|
<module>launcher</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
<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>
|
||||||
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
|
<project.organization.name>ZernMC</project.organization.name>
|
||||||
|
<project.inceptionYear>2026</project.inceptionYear>
|
||||||
|
<project.description>ZernMC Launcher - Multi-module project</project.description>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencyManagement>
|
||||||
<dependency>
|
<dependencies>
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
<dependency>
|
||||||
<artifactId>httpclient</artifactId>
|
<groupId>org.apache.httpcomponents</groupId>
|
||||||
<version>4.5.14</version>
|
<artifactId>httpclient</artifactId>
|
||||||
</dependency>
|
<version>4.5.14</version>
|
||||||
<dependency>
|
</dependency>
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
<dependency>
|
||||||
<artifactId>jackson-databind</artifactId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
<version>2.15.2</version>
|
<artifactId>jackson-databind</artifactId>
|
||||||
</dependency>
|
<version>2.15.2</version>
|
||||||
<dependency>
|
</dependency>
|
||||||
<groupId>com.google.code.gson</groupId>
|
<dependency>
|
||||||
<artifactId>gson</artifactId>
|
<groupId>com.google.code.gson</groupId>
|
||||||
<version>2.10.1</version>
|
<artifactId>gson</artifactId>
|
||||||
</dependency>
|
<version>2.10.1</version>
|
||||||
<dependency>
|
</dependency>
|
||||||
<groupId>org.json</groupId>
|
<dependency>
|
||||||
<artifactId>json</artifactId>
|
<groupId>org.json</groupId>
|
||||||
<version>20231013</version>
|
<artifactId>json</artifactId>
|
||||||
</dependency>
|
<version>20231013</version>
|
||||||
<dependency>
|
</dependency>
|
||||||
<groupId>org.fusesource.jansi</groupId>
|
<dependency>
|
||||||
<artifactId>jansi</artifactId>
|
<groupId>org.fusesource.jansi</groupId>
|
||||||
<version>2.4.1</version>
|
<artifactId>jansi</artifactId>
|
||||||
</dependency>
|
<version>2.4.1</version>
|
||||||
<dependency>
|
</dependency>
|
||||||
<groupId>me.tongfei</groupId>
|
<dependency>
|
||||||
<artifactId>progressbar</artifactId>
|
<groupId>org.jline</groupId>
|
||||||
<version>0.9.5</version>
|
<artifactId>jline</artifactId>
|
||||||
</dependency>
|
<version>3.24.1</version>
|
||||||
<dependency>
|
</dependency>
|
||||||
<groupId>commons-io</groupId>
|
<dependency>
|
||||||
<artifactId>commons-io</artifactId>
|
<groupId>me.tongfei</groupId>
|
||||||
<version>2.15.1</version>
|
<artifactId>progressbar</artifactId>
|
||||||
</dependency>
|
<version>0.9.5</version>
|
||||||
</dependencies>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>commons-io</groupId>
|
||||||
|
<artifactId>commons-io</artifactId>
|
||||||
|
<version>2.15.1</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- JavaFX for Windows -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-controls</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-web</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-graphics</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-base</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjfx</groupId>
|
||||||
|
<artifactId>javafx-media</artifactId>
|
||||||
|
<version>21</version>
|
||||||
|
<classifier>win</classifier>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -68,7 +122,7 @@
|
|||||||
<goal>shade</goal>
|
<goal>shade</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<outputFile>../server/builds/ZernMCLauncher.jar</outputFile>
|
<outputFile>../../server/builds/ZernMCLauncher.jar</outputFile>
|
||||||
<transformers>
|
<transformers>
|
||||||
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||||
<mainClass>${mainClass}</mainClass>
|
<mainClass>${mainClass}</mainClass>
|
||||||
@@ -76,81 +130,40 @@
|
|||||||
<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>
|
||||||
</transformers>
|
</transformers>
|
||||||
</configuration>
|
</configuration>
|
||||||
</execution>
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
|
||||||
|
|
||||||
<!-- Launch4j для создания .exe -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.akathist.maven.plugins.launch4j</groupId>
|
|
||||||
<artifactId>launch4j-maven-plugin</artifactId>
|
|
||||||
<version>2.5.0</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>l4j</id>
|
|
||||||
<phase>package</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>launch4j</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<outfile>../server/builds/ZernMCLauncher.exe</outfile>
|
|
||||||
<jar>../server/builds/ZernMCLauncher.jar</jar>
|
|
||||||
<headerType>console</headerType>
|
|
||||||
<dontWrapJar>false</dontWrapJar>
|
|
||||||
<jre>
|
|
||||||
<path>jre21</path>
|
|
||||||
<minVersion>21</minVersion>
|
|
||||||
</jre>
|
|
||||||
<versionInfo>
|
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
|
||||||
<txtFileVersion>${project.version}</txtFileVersion>
|
|
||||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
|
||||||
<productVersion>${project.version}.0</productVersion>
|
|
||||||
<txtProductVersion>${project.version}</txtProductVersion>
|
|
||||||
<productName>ZernMC Launcher</productName>
|
|
||||||
<companyName>ZernMC(SashegDev)</companyName>
|
|
||||||
<internalName>ZernMCLauncher</internalName>
|
|
||||||
<originalFilename>ZernMCLauncher.exe</originalFilename>
|
|
||||||
</versionInfo>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
|
|
||||||
<!-- Antrun: копирование JRE и создание build.version + zip -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-antrun-plugin</artifactId>
|
|
||||||
<version>3.1.0</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<phase>package</phase>
|
|
||||||
<goals><goal>run</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<target>
|
|
||||||
<echo file="../server/builds/build.version">${project.version}</echo>
|
|
||||||
|
|
||||||
<!-- Копируем содержимое jre/jre21 в папку jre21 (без лишней вложенности) -->
|
|
||||||
<copy todir="../server/builds/jre21" overwrite="true">
|
|
||||||
<fileset dir="${user.home}/launcher/jre/jre21"/>
|
|
||||||
</copy>
|
|
||||||
|
|
||||||
<!-- Создаём zip только с .exe и jre21 (без .jar и build.version) -->
|
|
||||||
<zip destfile="../server/builds/ZernMCLauncher-${project.version}.zip"
|
|
||||||
basedir="../server/builds"
|
|
||||||
includes="ZernMCLauncher.exe,jre21/**"
|
|
||||||
excludes="*.jar,build.version"/>
|
|
||||||
</target>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</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,182 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher;
|
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
|
||||||
import me.sashegdev.zernmc.launcher.menu.*;
|
|
||||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.*;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
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.StandardCopyOption;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class Main {
|
|
||||||
|
|
||||||
private static final String CURRENT_VERSION = Version.getCurrentVersion();
|
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException {
|
|
||||||
System.setProperty("org.jline.terminal.disableDeprecatedProviderWarning", "true");
|
|
||||||
System.setProperty("file.encoding", "UTF-8");
|
|
||||||
System.setProperty("sun.err.encoding", "UTF-8");
|
|
||||||
System.setProperty("sun.stdout.encoding", "UTF-8");
|
|
||||||
java.nio.charset.Charset.defaultCharset();
|
|
||||||
ZAnsi.install();
|
|
||||||
|
|
||||||
System.out.print("\033[H\033[2J");
|
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать в ZernMC Launcher " + CURRENT_VERSION));
|
|
||||||
|
|
||||||
//проверка всех сервисов при старте
|
|
||||||
ZHttpClient.checkAllServicesOnStartup();
|
|
||||||
|
|
||||||
checkAndAutoUpdateLauncher();
|
|
||||||
|
|
||||||
// === АВТОРИЗАЦИЯ ===
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка авторизации..."));
|
|
||||||
boolean sessionRestored = AuthManager.loadSavedSession();
|
|
||||||
|
|
||||||
if (!sessionRestored) {
|
|
||||||
LoginMenu loginMenu = new LoginMenu();
|
|
||||||
boolean loggedIn = loginMenu.show();
|
|
||||||
if (!loggedIn) {
|
|
||||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
|
||||||
ZAnsi.uninstall();
|
|
||||||
System.exit(0);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightGreen("Добро пожаловать обратно, " + AuthManager.getUsername() + "!"));
|
|
||||||
}
|
|
||||||
// === КОНЕЦ АВТОРИЗАЦИИ ===
|
|
||||||
|
|
||||||
try {
|
|
||||||
mainLoop();
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println(ZAnsi.brightRed("Критическая ошибка: " + e.getMessage()));
|
|
||||||
e.printStackTrace();
|
|
||||||
} finally {
|
|
||||||
ZAnsi.uninstall();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void checkAndAutoUpdateLauncher() {
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка обновлений лаунчера..."));
|
|
||||||
|
|
||||||
try {
|
|
||||||
String json = ZHttpClient.getLauncherVersionInfo();
|
|
||||||
String serverVersion = extractVersion(json);
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.white("Текущая версия: ") + CURRENT_VERSION);
|
|
||||||
System.out.println(ZAnsi.white("Версия на сервере: ") + serverVersion);
|
|
||||||
|
|
||||||
if (Version.isNewer(CURRENT_VERSION, serverVersion)) {
|
|
||||||
System.out.println(ZAnsi.brightYellow("\nДоступна новая версия лаунчера! (" + serverVersion + ")"));
|
|
||||||
System.out.println(ZAnsi.cyan("Начинается автоматическое обновление...\n"));
|
|
||||||
|
|
||||||
performAutoUpdate(serverVersion);
|
|
||||||
restartLauncher();
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightGreen("Лаунчер актуален."));
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.yellow("Не удалось проверить обновления лаунчера."));
|
|
||||||
System.out.println(ZAnsi.white("Ошибка: ") + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void performAutoUpdate(String newVersion) throws Exception {
|
|
||||||
String downloadUrl = ZHttpClient.getBaseUrl() + "/launcher/download?type=jar";
|
|
||||||
Path currentJar = getCurrentJarPath();
|
|
||||||
Path tempJar = currentJar.getParent().resolve("zernmc-launcher-new.jar");
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Скачивание версии " + newVersion + "..."));
|
|
||||||
|
|
||||||
HttpClient client = HttpClient.newBuilder().build();
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(java.net.URI.create(downloadUrl))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Path> response = client.send(request, HttpResponse.BodyHandlers.ofFile(tempJar));
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("Сервер вернул код: " + response.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
long size = Files.size(tempJar);
|
|
||||||
System.out.println(ZAnsi.brightGreen("Скачано успешно (" + (size / 1024) + " KB)"));
|
|
||||||
|
|
||||||
// Заменяем текущий jar
|
|
||||||
Files.move(tempJar, currentJar, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Обновление успешно установлено!"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void restartLauncher() {
|
|
||||||
try {
|
|
||||||
String javaPath = System.getProperty("java.home") + "/bin/java";
|
|
||||||
String jarPath = getCurrentJarPath().toAbsolutePath().toString();
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.brightGreen("Перезапуск лаунчера с новой версией..."));
|
|
||||||
|
|
||||||
new ProcessBuilder(javaPath, "-jar", jarPath)
|
|
||||||
.inheritIO()
|
|
||||||
.start();
|
|
||||||
|
|
||||||
System.exit(0);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println(ZAnsi.brightRed("Не удалось перезапустить лаунчер."));
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String extractVersion(String json) {
|
|
||||||
try {
|
|
||||||
return json.replaceAll(".*\"version\"\\s*:\\s*\"([^\"]+)\".*", "$1");
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Path getCurrentJarPath() {
|
|
||||||
try {
|
|
||||||
return Path.of(Main.class.getProtectionDomain()
|
|
||||||
.getCodeSource()
|
|
||||||
.getLocation()
|
|
||||||
.toURI());
|
|
||||||
} catch (Exception e) {
|
|
||||||
return Path.of("zernmc-launcher-1.0-jar-with-dependencies.jar");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void mainLoop() throws Exception {
|
|
||||||
while (true) {
|
|
||||||
List<String> options = List.of(
|
|
||||||
"Запустить игру",
|
|
||||||
"Проверка обновлений",
|
|
||||||
"Настройки",
|
|
||||||
"Проверка подключения к серверам Zern",
|
|
||||||
"Выход"
|
|
||||||
);
|
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Главное меню", options);
|
|
||||||
int choice = menu.show();
|
|
||||||
|
|
||||||
if (choice == -1 || choice == 4) {
|
|
||||||
System.out.print("\033[H\033[2J");
|
|
||||||
System.out.println(ZAnsi.yellow("До свидания!"));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (choice) {
|
|
||||||
case 0 -> new LaunchMenu().show();
|
|
||||||
case 1 -> new UpdateMenu().show();
|
|
||||||
case 2 -> new SettingsMenu().show();
|
|
||||||
case 3 -> new ServerCheckMenu().show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.menu;
|
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.ui.ArrowMenu;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ConsoleUtils;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.http.HttpClient;
|
|
||||||
import java.net.http.HttpRequest;
|
|
||||||
import java.net.http.HttpResponse;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class ServerCheckMenu {
|
|
||||||
|
|
||||||
public void show() throws IOException {
|
|
||||||
List<String> options = List.of(
|
|
||||||
"Проверить подключение к ZernMC серверу",
|
|
||||||
"Проверить доступ к Mojang (Minecraft)",
|
|
||||||
"Проверить доступ к Fabric Meta",
|
|
||||||
"Назад в главное меню"
|
|
||||||
);
|
|
||||||
|
|
||||||
ArrowMenu menu = new ArrowMenu("Диагностика подключения", options);
|
|
||||||
int choice = menu.show();
|
|
||||||
|
|
||||||
if (choice == -1 || choice == 4) return;
|
|
||||||
|
|
||||||
ConsoleUtils.clearScreen();
|
|
||||||
|
|
||||||
switch (choice) {
|
|
||||||
case 0 -> checkZernServer();
|
|
||||||
case 1 -> checkMojang();
|
|
||||||
case 2 -> checkFabric();
|
|
||||||
}
|
|
||||||
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkZernServer() {
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка подключения к ZernMC серверу..."));
|
|
||||||
try {
|
|
||||||
String response = ZHttpClient.get("/health");
|
|
||||||
System.out.println(ZAnsi.brightGreen("Сервер успешно подключён!"));
|
|
||||||
System.out.println("Ответ: " + response);
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Не удалось подключиться к ZernMC серверу"));
|
|
||||||
System.out.println("Ошибка: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkMojang() {
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка доступа к Mojang..."));
|
|
||||||
try {
|
|
||||||
HttpClient client = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(8))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create("https://launchermeta.mojang.com/mc/game/version_manifest_v2.json"))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
|
||||||
System.out.println(ZAnsi.brightGreen("Mojang доступен"));
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightRed("Mojang вернул код " + response.statusCode()));
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Нет доступа к Mojang"));
|
|
||||||
System.out.println("Ошибка: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkFabric() {
|
|
||||||
System.out.println(ZAnsi.cyan("Проверка доступа к Fabric Meta..."));
|
|
||||||
try {
|
|
||||||
HttpClient client = HttpClient.newBuilder()
|
|
||||||
.connectTimeout(Duration.ofSeconds(8))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create("https://meta.fabricmc.net/v2/versions"))
|
|
||||||
.GET()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (response.statusCode() == 200) {
|
|
||||||
System.out.println(ZAnsi.brightGreen("Fabric Meta доступен"));
|
|
||||||
} else {
|
|
||||||
System.out.println(ZAnsi.brightRed("Fabric Meta вернул код " + response.statusCode()));
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Нет доступа к Fabric Meta"));
|
|
||||||
System.out.println("Ошибка: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-373
@@ -1,373 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.minecraft.launch;
|
|
||||||
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.Instance;
|
|
||||||
import me.sashegdev.zernmc.launcher.minecraft.model.LaunchOptions;
|
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
|
||||||
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class LaunchCommandBuilder {
|
|
||||||
|
|
||||||
private final Instance instance;
|
|
||||||
|
|
||||||
public LaunchCommandBuilder(Instance instance) {
|
|
||||||
this.instance = instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<String> build(LaunchOptions options) throws Exception {
|
|
||||||
System.out.println(ZAnsi.cyan("Генерация команды запуска для " + instance.getName() + "..."));
|
|
||||||
|
|
||||||
List<String> command = new ArrayList<>();
|
|
||||||
|
|
||||||
// 1. Путь к Java
|
|
||||||
String javaPath = getJavaPath();
|
|
||||||
command.add(javaPath);
|
|
||||||
|
|
||||||
// 2. JVM аргументы
|
|
||||||
command.addAll(getJvmArguments(options));
|
|
||||||
|
|
||||||
// 3. Natives
|
|
||||||
Path nativesDir = instance.getPath().resolve("natives");
|
|
||||||
if (!Files.exists(nativesDir)) {
|
|
||||||
Files.createDirectories(nativesDir);
|
|
||||||
}
|
|
||||||
command.add("-Djava.library.path=" + nativesDir.toAbsolutePath());
|
|
||||||
|
|
||||||
String loaderType = instance.getLoaderType().toLowerCase();
|
|
||||||
|
|
||||||
if ("forge".equals(loaderType)) {
|
|
||||||
command.addAll(getForgeJvmArguments());
|
|
||||||
command.add("-cp");
|
|
||||||
command.add(buildForgeClasspath());
|
|
||||||
command.add("cpw.mods.modlauncher.Launcher");
|
|
||||||
command.addAll(getForgeArguments(options));
|
|
||||||
} else {
|
|
||||||
command.add("-cp");
|
|
||||||
command.add(buildClasspath());
|
|
||||||
command.add(getMainClass());
|
|
||||||
command.addAll(getMinecraftArguments(options));
|
|
||||||
}
|
|
||||||
|
|
||||||
return command;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getJavaPath() {
|
|
||||||
return "java";
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> getJvmArguments(LaunchOptions options) {
|
|
||||||
List<String> jvmArgs = new ArrayList<>();
|
|
||||||
|
|
||||||
int ramMB = options.getMaxMemory() > 0 ? options.getMaxMemory() : 4096;
|
|
||||||
jvmArgs.add("-Xmx" + ramMB + "M");
|
|
||||||
jvmArgs.add("-Xms" + Math.max(512, ramMB / 2) + "M");
|
|
||||||
|
|
||||||
jvmArgs.add("-XX:+UseG1GC");
|
|
||||||
jvmArgs.add("-XX:+UnlockExperimentalVMOptions");
|
|
||||||
jvmArgs.add("-XX:G1NewSizePercent=20");
|
|
||||||
jvmArgs.add("-XX:G1ReservePercent=20");
|
|
||||||
jvmArgs.add("-XX:MaxGCPauseMillis=50");
|
|
||||||
jvmArgs.add("-XX:G1HeapRegionSize=32M");
|
|
||||||
|
|
||||||
String loaderType = instance.getLoaderType().toLowerCase();
|
|
||||||
|
|
||||||
if ("fabric".equals(loaderType)) {
|
|
||||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.lang=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.getExtraJvmArgs() != null && !options.getExtraJvmArgs().isEmpty()) {
|
|
||||||
jvmArgs.addAll(options.getExtraJvmArgs());
|
|
||||||
}
|
|
||||||
|
|
||||||
return jvmArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> getForgeJvmArguments() {
|
|
||||||
List<String> jvmArgs = new ArrayList<>();
|
|
||||||
|
|
||||||
jvmArgs.add("--add-modules=ALL-MODULE-PATH");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.util.jar=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.io=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.nio=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.net=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
|
|
||||||
jvmArgs.add("--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED");
|
|
||||||
|
|
||||||
jvmArgs.add("-Dforge.logging.console.level=debug");
|
|
||||||
jvmArgs.add("-Dforge.logging.mojang.level=info");
|
|
||||||
jvmArgs.add("-DignoreList=bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-");
|
|
||||||
jvmArgs.add("-DmergeModules=jna-5.10.0.jar,jna-platform-5.10.0.jar");
|
|
||||||
|
|
||||||
return jvmArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildClasspath() throws Exception {
|
|
||||||
List<String> paths = new ArrayList<>();
|
|
||||||
|
|
||||||
String versionId = getVersionId();
|
|
||||||
|
|
||||||
Path versionJar = instance.getPath()
|
|
||||||
.resolve("versions")
|
|
||||||
.resolve(versionId)
|
|
||||||
.resolve(versionId + ".jar");
|
|
||||||
|
|
||||||
if (Files.exists(versionJar)) {
|
|
||||||
paths.add(versionJar.toAbsolutePath().toString());
|
|
||||||
} else {
|
|
||||||
String mcVersion = instance.getMinecraftVersion();
|
|
||||||
Path fallbackJar = instance.getPath()
|
|
||||||
.resolve("versions")
|
|
||||||
.resolve(mcVersion)
|
|
||||||
.resolve(mcVersion + ".jar");
|
|
||||||
if (Files.exists(fallbackJar)) {
|
|
||||||
paths.add(fallbackJar.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Path librariesDir = instance.getPath().resolve("libraries");
|
|
||||||
if (Files.exists(librariesDir)) {
|
|
||||||
try (var stream = Files.walk(librariesDir)) {
|
|
||||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
|
||||||
.map(p -> p.toAbsolutePath().toString())
|
|
||||||
.forEach(paths::add);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
|
||||||
return String.join(separator, paths);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildForgeClasspath() throws Exception {
|
|
||||||
List<String> paths = new ArrayList<>();
|
|
||||||
|
|
||||||
String versionId = getVersionId();
|
|
||||||
String mcVersion = instance.getMinecraftVersion();
|
|
||||||
String forgeVersion = instance.getLoaderVersion();
|
|
||||||
|
|
||||||
Path librariesDir = instance.getPath().resolve("libraries");
|
|
||||||
if (Files.exists(librariesDir)) {
|
|
||||||
try (var stream = Files.walk(librariesDir)) {
|
|
||||||
stream.filter(p -> p.toString().endsWith(".jar"))
|
|
||||||
.map(p -> p.toAbsolutePath().toString())
|
|
||||||
.forEach(paths::add);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Path versionJar = instance.getPath()
|
|
||||||
.resolve("versions")
|
|
||||||
.resolve(versionId)
|
|
||||||
.resolve(versionId + ".jar");
|
|
||||||
if (Files.exists(versionJar)) {
|
|
||||||
paths.add(0, versionJar.toAbsolutePath().toString());
|
|
||||||
} else {
|
|
||||||
Path vanillaJar = instance.getPath()
|
|
||||||
.resolve("versions")
|
|
||||||
.resolve(mcVersion)
|
|
||||||
.resolve(mcVersion + ".jar");
|
|
||||||
if (Files.exists(vanillaJar)) {
|
|
||||||
paths.add(0, vanillaJar.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Path forgeUniversal = instance.getPath()
|
|
||||||
.resolve("libraries")
|
|
||||||
.resolve("net")
|
|
||||||
.resolve("minecraftforge")
|
|
||||||
.resolve("forge")
|
|
||||||
.resolve(mcVersion + "-" + forgeVersion)
|
|
||||||
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-universal.jar");
|
|
||||||
if (Files.exists(forgeUniversal)) {
|
|
||||||
paths.add(forgeUniversal.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
Path forgeClient = instance.getPath()
|
|
||||||
.resolve("libraries")
|
|
||||||
.resolve("net")
|
|
||||||
.resolve("minecraftforge")
|
|
||||||
.resolve("forge")
|
|
||||||
.resolve(mcVersion + "-" + forgeVersion)
|
|
||||||
.resolve("forge-" + mcVersion + "-" + forgeVersion + "-client.jar");
|
|
||||||
if (Files.exists(forgeClient)) {
|
|
||||||
paths.add(forgeClient.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
String[] forgeModules = {"fmlcore", "javafmllanguage", "lowcodelanguage", "mclanguage"};
|
|
||||||
for (String module : forgeModules) {
|
|
||||||
Path modulePath = instance.getPath()
|
|
||||||
.resolve("libraries")
|
|
||||||
.resolve("net")
|
|
||||||
.resolve("minecraftforge")
|
|
||||||
.resolve(module)
|
|
||||||
.resolve(mcVersion + "-" + forgeVersion)
|
|
||||||
.resolve(module + "-" + mcVersion + "-" + forgeVersion + ".jar");
|
|
||||||
if (Files.exists(modulePath)) {
|
|
||||||
paths.add(modulePath.toAbsolutePath().toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String separator = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":";
|
|
||||||
return String.join(separator, paths);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getMainClass() {
|
|
||||||
String loaderType = instance.getLoaderType().toLowerCase();
|
|
||||||
|
|
||||||
if ("fabric".equals(loaderType)) {
|
|
||||||
return "net.fabricmc.loader.impl.launch.knot.KnotClient";
|
|
||||||
}
|
|
||||||
else if ("forge".equals(loaderType)) {
|
|
||||||
return "cpw.mods.modlauncher.Launcher";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return "net.minecraft.client.main.Main";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ИСПРАВЛЕНО: используем 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package me.sashegdev.zernmc.launcher.utils;
|
|
||||||
|
|
||||||
import org.fusesource.jansi.Ansi;
|
|
||||||
import org.fusesource.jansi.AnsiConsole;
|
|
||||||
|
|
||||||
public class ZAnsi {
|
|
||||||
|
|
||||||
//поддержка ANSI епта
|
|
||||||
public static void install() {
|
|
||||||
AnsiConsole.systemInstall();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void uninstall() {
|
|
||||||
AnsiConsole.systemUninstall();
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Основные цвета ===
|
|
||||||
public static String green(String text) {
|
|
||||||
return Ansi.ansi().fg(Ansi.Color.GREEN).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String brightGreen(String text) {
|
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.GREEN).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String cyan(String text) {
|
|
||||||
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String yellow(String text) {
|
|
||||||
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String brightYellow(String text) {
|
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String red(String text) {
|
|
||||||
return Ansi.ansi().fg(Ansi.Color.RED).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String brightRed(String text) {
|
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.RED).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String blue(String text) {
|
|
||||||
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String white(String text) {
|
|
||||||
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String brightWhite(String text) {
|
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Стили
|
|
||||||
public static String bold(String text) {
|
|
||||||
return Ansi.ansi().bold().a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String reset() {
|
|
||||||
return Ansi.ansi().reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Комбинированные удобные методы
|
|
||||||
public static String header(String text) {
|
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String selected(String text) {
|
|
||||||
return Ansi.ansi()
|
|
||||||
.bgBright(Ansi.Color.WHITE)
|
|
||||||
.fgBlack()
|
|
||||||
.a(" > " + text + " ")
|
|
||||||
.reset()
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+570
-225
@@ -8,25 +8,36 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from cachetools import TTLCache
|
||||||
|
from fastapi import APIRouter, HTTPException, Request, Depends, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
import re
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
# ====================== КОНФИГ ======================
|
# ====================== КОНФИГ ======================
|
||||||
AUTH_DB = Path("data/auth.db")
|
AUTH_DB = Path("data/auth.db")
|
||||||
AUTH_DB.parent.mkdir(exist_ok=True)
|
AUTH_DB.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
SECRET_KEY = Path("data/.secret_key")
|
SECRET_KEY = Path("data/.secret_key")
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа
|
# Токены
|
||||||
REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||||
|
|
||||||
|
# Лимиты
|
||||||
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
|
LOGIN_BLOCK_MINUTES = 15
|
||||||
|
|
||||||
|
# Rate limiting — in-memory TTL cache (1 hour TTL, max 1000 IPs)
|
||||||
|
_rate_limit_cache: TTLCache = TTLCache(maxsize=1000, ttl=LOGIN_BLOCK_MINUTES * 60 * 4)
|
||||||
|
|
||||||
# ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
|
# ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
|
||||||
def _get_secret() -> bytes:
|
def _get_secret() -> bytes:
|
||||||
|
"""Безопасное получение/создание секретного ключа"""
|
||||||
if SECRET_KEY.exists():
|
if SECRET_KEY.exists():
|
||||||
return SECRET_KEY.read_bytes()
|
return SECRET_KEY.read_bytes()
|
||||||
key = secrets.token_bytes(64)
|
key = secrets.token_bytes(64)
|
||||||
@@ -36,31 +47,46 @@ def _get_secret() -> bytes:
|
|||||||
|
|
||||||
_SECRET = _get_secret()
|
_SECRET = _get_secret()
|
||||||
|
|
||||||
def create_jwt(payload: dict) -> str:
|
# ====================== JWT ФУНКЦИИ ======================
|
||||||
|
def create_jwt(payload: dict, expires_in: int = None) -> str:
|
||||||
|
"""Создание JWT токена"""
|
||||||
|
if expires_in is None:
|
||||||
|
expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
|
|
||||||
|
payload = payload.copy()
|
||||||
|
payload["exp"] = time.time() + expires_in
|
||||||
|
payload["iat"] = time.time()
|
||||||
|
payload["jti"] = secrets.token_hex(16)
|
||||||
|
|
||||||
header = base64.urlsafe_b64encode(
|
header = base64.urlsafe_b64encode(
|
||||||
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
|
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
|
||||||
).rstrip(b'=').decode()
|
).rstrip(b'=').decode()
|
||||||
|
|
||||||
body = base64.urlsafe_b64encode(
|
body = base64.urlsafe_b64encode(
|
||||||
json.dumps(payload).encode()
|
json.dumps(payload).encode()
|
||||||
).rstrip(b'=').decode()
|
).rstrip(b'=').decode()
|
||||||
|
|
||||||
msg = f"{header}.{body}".encode()
|
msg = f"{header}.{body}".encode()
|
||||||
sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
|
sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
|
||||||
|
|
||||||
return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
|
return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
|
||||||
|
|
||||||
def verify_jwt(token: str) -> Optional[dict]:
|
def verify_jwt(token: str) -> Optional[dict]:
|
||||||
|
"""Верификация JWT токена"""
|
||||||
try:
|
try:
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
return None
|
return None
|
||||||
header, body, sig = parts
|
|
||||||
msg = f"{header}.{body}".encode()
|
|
||||||
expected = hmac.new(_SECRET, msg, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
# Исправлено: правильный паддинг для base64url
|
header, body, sig = parts
|
||||||
|
|
||||||
sig_padded = sig + '=' * (4 - len(sig) % 4)
|
sig_padded = sig + '=' * (4 - len(sig) % 4)
|
||||||
|
expected_sig = base64.urlsafe_b64decode(sig_padded)
|
||||||
|
|
||||||
|
msg = f"{header}.{body}".encode()
|
||||||
if not hmac.compare_digest(
|
if not hmac.compare_digest(
|
||||||
base64.urlsafe_b64decode(sig_padded),
|
hmac.new(_SECRET, msg, hashlib.sha256).digest(),
|
||||||
expected
|
expected_sig
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -69,75 +95,141 @@ def verify_jwt(token: str) -> Optional[dict]:
|
|||||||
|
|
||||||
if payload.get("exp", 0) < time.time():
|
if payload.get("exp", 0) < time.time():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ====================== БАЗА ДАННЫХ ======================
|
# ====================== БАЗА ДАННЫХ ======================
|
||||||
|
@contextmanager
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False)
|
"""Контекстный менеджер для БД"""
|
||||||
|
conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
conn = get_db()
|
"""Инициализация основной БД"""
|
||||||
conn.executescript("""
|
with get_db() as conn:
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
conn.executescript("PRAGMA journal_mode=WAL;")
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
conn.executescript("""
|
||||||
username TEXT UNIQUE COLLATE NOCASE,
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
password_hash TEXT NOT NULL,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE COLLATE NOCASE,
|
||||||
created_at REAL NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
last_login REAL
|
uuid TEXT UNIQUE NOT NULL,
|
||||||
);
|
role INTEGER DEFAULT 0,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
last_login REAL,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
banned_until REAL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS passes (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
code TEXT PRIMARY KEY,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
owner TEXT,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
is_active BOOLEAN DEFAULT 1,
|
token_hash TEXT NOT NULL,
|
||||||
activated_by INTEGER REFERENCES users(id),
|
jti TEXT NOT NULL,
|
||||||
activated_at REAL,
|
expires_at REAL NOT NULL,
|
||||||
expires_at REAL,
|
revoked BOOLEAN DEFAULT 0,
|
||||||
max_uses INTEGER DEFAULT 1,
|
created_at REAL NOT NULL
|
||||||
uses INTEGER DEFAULT 0
|
);
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_passes (
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
pass_code TEXT REFERENCES passes(code),
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
activated_at REAL NOT NULL,
|
session_token TEXT UNIQUE NOT NULL,
|
||||||
PRIMARY KEY (user_id, pass_code)
|
ip_address TEXT,
|
||||||
);
|
user_agent TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
expires_at REAL NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS passes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
owner TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
activated_by INTEGER REFERENCES users(id),
|
||||||
|
activated_at REAL,
|
||||||
|
expires_at REAL,
|
||||||
|
max_uses INTEGER DEFAULT 1,
|
||||||
|
uses INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_passes (
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
pass_code TEXT REFERENCES passes(code),
|
||||||
|
activated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, pass_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pass_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
requester_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
target_username TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
decision_reason TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
reviewed_by INTEGER REFERENCES users(id),
|
||||||
|
reviewed_at REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
timestamp REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Добавляем колонку role если её нет
|
||||||
|
cursor = conn.execute("PRAGMA table_info(users)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "role" not in columns:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0")
|
||||||
|
logger.info("Added role column to users table")
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
expires_at REAL NOT NULL
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
logger.info("Auth database initialized")
|
logger.info("Auth database initialized")
|
||||||
|
|
||||||
# ====================== ХЕЛПЕРЫ ======================
|
# ====================== ХЕЛПЕРЫ ======================
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
salt = secrets.token_hex(16)
|
"""Хэширование пароля"""
|
||||||
|
salt = secrets.token_hex(32)
|
||||||
hash_obj = hashlib.pbkdf2_hmac(
|
hash_obj = hashlib.pbkdf2_hmac(
|
||||||
'sha256',
|
'sha256',
|
||||||
password.encode(),
|
password.encode('utf-8'),
|
||||||
salt.encode(),
|
salt.encode('utf-8'),
|
||||||
300000
|
300000
|
||||||
)
|
)
|
||||||
return f"{salt}${hash_obj.hex()}"
|
return f"{salt}${hash_obj.hex()}"
|
||||||
|
|
||||||
def verify_password(password: str, stored: str) -> bool:
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
"""Верификация пароля"""
|
||||||
try:
|
try:
|
||||||
salt, stored_hash = stored.split('$')
|
salt, stored_hash = stored.split('$')
|
||||||
hash_obj = hashlib.pbkdf2_hmac(
|
hash_obj = hashlib.pbkdf2_hmac(
|
||||||
'sha256',
|
'sha256',
|
||||||
password.encode(),
|
password.encode('utf-8'),
|
||||||
salt.encode(),
|
salt.encode('utf-8'),
|
||||||
300000
|
300000
|
||||||
)
|
)
|
||||||
return hmac.compare_digest(hash_obj.hex(), stored_hash)
|
return hmac.compare_digest(hash_obj.hex(), stored_hash)
|
||||||
@@ -145,123 +237,320 @@ def verify_password(password: str, stored: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def generate_uuid() -> str:
|
def generate_uuid() -> str:
|
||||||
|
"""Генерация UUID"""
|
||||||
return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
|
return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
|
||||||
|
|
||||||
|
def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]:
|
||||||
|
"""Check rate limiting — blocks IP after MAX_LOGIN_ATTEMPTS failed attempts"""
|
||||||
|
now = time.time()
|
||||||
|
entry = _rate_limit_cache.get(ip)
|
||||||
|
|
||||||
|
if entry is None:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# Check if currently blocked
|
||||||
|
if entry.get("blocked_until", 0) > now:
|
||||||
|
remaining = int(entry["blocked_until"] - now)
|
||||||
|
return False, remaining
|
||||||
|
|
||||||
|
# Reset block if expired
|
||||||
|
if entry["attempts"] >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60)
|
||||||
|
_rate_limit_cache[ip] = entry
|
||||||
|
return False, LOGIN_BLOCK_MINUTES * 60
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def record_login_attempt(ip: str, success: bool):
|
||||||
|
"""Record login attempt — resets on success, increments on failure"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
_rate_limit_cache.pop(ip, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
entry = _rate_limit_cache.get(ip, {"attempts": 0, "blocked_until": 0})
|
||||||
|
entry["attempts"] += 1
|
||||||
|
entry["last_attempt"] = now
|
||||||
|
|
||||||
|
if entry["attempts"] >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
entry["blocked_until"] = now + (LOGIN_BLOCK_MINUTES * 60)
|
||||||
|
|
||||||
|
_rate_limit_cache[ip] = entry
|
||||||
|
|
||||||
|
def log_audit(user_id: int, action: str, details: str, ip_address: str):
|
||||||
|
"""Логирование действий"""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audit_log (user_id, action, details, ip_address, timestamp) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user_id, action, details, ip_address, time.time())
|
||||||
|
)
|
||||||
|
|
||||||
# ====================== МОДЕЛИ ======================
|
# ====================== МОДЕЛИ ======================
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str = Field(..., min_length=3, max_length=32)
|
||||||
password: str
|
password: str = Field(..., min_length=6, max_length=128)
|
||||||
|
|
||||||
|
@field_validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_]+$', v):
|
||||||
|
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$")
|
username: str = Field(..., min_length=3, max_length=32)
|
||||||
password: str = Field(..., min_length=6, max_length=128)
|
password: str = Field(..., min_length=6, max_length=128)
|
||||||
|
|
||||||
|
@field_validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_]+$', v):
|
||||||
|
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
expires_in: int
|
expires_in: int
|
||||||
|
token_type: str = "bearer"
|
||||||
username: str
|
username: str
|
||||||
uuid: str
|
uuid: str
|
||||||
|
role: int
|
||||||
|
role_name: str
|
||||||
|
|
||||||
# ====================== ROUTER ======================
|
# ====================== DEPENDENCIES ======================
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
bearer = HTTPBearer(auto_error=False)
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse:
|
async def get_current_user(
|
||||||
now = time.time()
|
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||||
|
request: Request = None
|
||||||
|
) -> dict:
|
||||||
|
"""Получение текущего пользователя"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Не авторизован",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
access_token = create_jwt({
|
payload = verify_jwt(credentials.credentials)
|
||||||
"sub": user_id,
|
if not payload or payload.get("type") != "access":
|
||||||
"username": username,
|
raise HTTPException(
|
||||||
"uuid": uuid,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
"type": "access",
|
detail="Недействительный токен"
|
||||||
"exp": now + ACCESS_TOKEN_EXPIRE_SECONDS
|
)
|
||||||
})
|
|
||||||
|
|
||||||
refresh_token = create_jwt({
|
with get_db() as conn:
|
||||||
"sub": user_id,
|
user = conn.execute(
|
||||||
"type": "refresh",
|
"SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?",
|
||||||
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS
|
(payload["sub"],)
|
||||||
})
|
).fetchone()
|
||||||
|
|
||||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
if not user:
|
||||||
|
raise HTTPException(401, "Пользователь не найден")
|
||||||
|
|
||||||
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,))
|
if not user["is_active"]:
|
||||||
conn.execute(
|
raise HTTPException(403, "Аккаунт деактивирован")
|
||||||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
|
|
||||||
(user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return TokenResponse(
|
if user["banned_until"] and user["banned_until"] > time.time():
|
||||||
access_token=access_token,
|
raise HTTPException(403, "Аккаунт забанен")
|
||||||
refresh_token=refresh_token,
|
|
||||||
expires_in=ACCESS_TOKEN_EXPIRE_SECONDS,
|
|
||||||
username=username,
|
|
||||||
uuid=uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user["id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"uuid": user["uuid"],
|
||||||
|
"role": user["role"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def require_role(min_role: int):
|
||||||
|
"""Dependency for checking minimum required role"""
|
||||||
|
from roles import UserRole
|
||||||
|
async def dependency(current_user: dict = Depends(get_current_user)):
|
||||||
|
if current_user["role"] < min_role:
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Требуется роль {ROLE_NAMES.get(min_role, 'неизвестная')}"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
# ====================== ЭНДПОИНТЫ ======================
|
||||||
@router.post("/register", response_model=TokenResponse)
|
@router.post("/register", response_model=TokenResponse)
|
||||||
async def register(body: RegisterRequest, request: Request):
|
async def register(body: RegisterRequest, request: Request):
|
||||||
conn = get_db()
|
"""Регистрация нового пользователя"""
|
||||||
try:
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
allowed, wait = check_rate_limit(ip)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT 1 FROM users WHERE username = ? COLLATE NOCASE",
|
"SELECT username FROM users WHERE username = ?",
|
||||||
(body.username,)
|
(body.username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=409, detail="Имя пользователя уже занято")
|
raise HTTPException(409, "Пользователь с таким именем уже существует")
|
||||||
|
|
||||||
uuid = generate_uuid()
|
uuid = generate_uuid()
|
||||||
pw_hash = hash_password(body.password)
|
pw_hash = hash_password(body.password)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)",
|
"""INSERT INTO users (username, password_hash, uuid, created_at, role)
|
||||||
(body.username, pw_hash, uuid, now)
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(body.username, pw_hash, uuid, now, 0) # role 0 = обычный пользователь
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
tokens = _issue_tokens(conn, user_id, body.username, uuid)
|
|
||||||
|
|
||||||
logger.info("User registered", username=body.username, user_id=user_id)
|
# Создаем сессию
|
||||||
return tokens
|
session_token = secrets.token_urlsafe(32)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(user_id, session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
|
||||||
|
)
|
||||||
|
|
||||||
except HTTPException:
|
# Токены
|
||||||
raise
|
access_token = create_jwt({
|
||||||
except Exception as e:
|
"sub": user_id,
|
||||||
logger.error("Register error", exc_info=True)
|
"username": body.username,
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
"uuid": uuid,
|
||||||
finally:
|
"role": 0,
|
||||||
conn.close()
|
"type": "access",
|
||||||
|
"jti": session_token
|
||||||
|
})
|
||||||
|
|
||||||
|
refresh_token = create_jwt({
|
||||||
|
"sub": user_id,
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": secrets.token_hex(16)
|
||||||
|
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
|
||||||
|
|
||||||
|
refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user_id, refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit(user_id, "register", f"User registered from {ip}", ip)
|
||||||
|
logger.info("User registered", username=body.username, user_id=user_id, ip=ip)
|
||||||
|
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
username=body.username,
|
||||||
|
uuid=uuid,
|
||||||
|
role=0,
|
||||||
|
role_name=ROLE_NAMES[0]
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, request: Request):
|
async def login(body: LoginRequest, request: Request):
|
||||||
conn = get_db()
|
"""Вход в систему"""
|
||||||
try:
|
ip = request.client.host if request.client else "unknown"
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE",
|
allowed, wait = check_rate_limit(ip)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT id, username, uuid, password_hash, role, is_active, banned_until FROM users WHERE username = ?",
|
||||||
(body.username,)
|
(body.username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row or not verify_password(body.password, row["password_hash"]):
|
if not user or not verify_password(body.password, user["password_hash"]):
|
||||||
|
record_login_attempt(ip, False)
|
||||||
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
||||||
|
|
||||||
|
if not user["is_active"]:
|
||||||
|
raise HTTPException(403, "Аккаунт деактивирован")
|
||||||
|
|
||||||
|
if user["banned_until"] and user["banned_until"] > time.time():
|
||||||
|
raise HTTPException(403, "Аккаунт забанен")
|
||||||
|
|
||||||
|
record_login_attempt(ip, True)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login = ? WHERE id = ?",
|
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||||
(time.time(), row["id"])
|
(now, user["id"])
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
logger.info("User logged in", username=body.username, user_id=row["id"])
|
# Создаем сессию
|
||||||
return _issue_tokens(conn, row["id"], row["username"], row["uuid"])
|
session_token = secrets.token_urlsafe(32)
|
||||||
finally:
|
conn.execute(
|
||||||
conn.close()
|
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_jwt({
|
||||||
|
"sub": user["id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"uuid": user["uuid"],
|
||||||
|
"role": user["role"],
|
||||||
|
"type": "access",
|
||||||
|
"jti": session_token
|
||||||
|
})
|
||||||
|
|
||||||
|
refresh_token = create_jwt({
|
||||||
|
"sub": user["id"],
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": secrets.token_hex(16)
|
||||||
|
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
|
||||||
|
|
||||||
|
refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user["id"], refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = user["id"]
|
||||||
|
username = user["username"]
|
||||||
|
user_role = user["role"]
|
||||||
|
|
||||||
|
log_audit(user_id, "login", f"User logged in from {ip}", ip)
|
||||||
|
logger.info("User logged in", username=username, user_id=user_id, ip=ip)
|
||||||
|
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
username=username,
|
||||||
|
uuid=user["uuid"],
|
||||||
|
role=user_role,
|
||||||
|
role_name=ROLE_NAMES.get(user_role, "Неизвестно")
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(current_user: dict = Depends(get_current_user), request: Request = None):
|
||||||
|
"""Выход из системы"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_sessions SET is_active = 0 WHERE user_id = ?",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
|
||||||
|
|
||||||
|
logger.info("User logged out", user_id=current_user["id"], ip=ip)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
async def refresh(body: dict):
|
async def refresh(body: dict, request: Request):
|
||||||
|
"""Обновление access токена"""
|
||||||
refresh_token = body.get("refresh_token")
|
refresh_token = body.get("refresh_token")
|
||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
raise HTTPException(400, "refresh_token обязателен")
|
raise HTTPException(400, "refresh_token обязателен")
|
||||||
@@ -270,55 +559,81 @@ async def refresh(body: dict):
|
|||||||
if not payload or payload.get("type") != "refresh":
|
if not payload or payload.get("type") != "refresh":
|
||||||
raise HTTPException(401, "Недействительный refresh token")
|
raise HTTPException(401, "Недействительный refresh token")
|
||||||
|
|
||||||
conn = get_db()
|
ip = request.client.host if request.client else "unknown"
|
||||||
try:
|
|
||||||
|
with get_db() as conn:
|
||||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||||
row = conn.execute(
|
token_row = conn.execute(
|
||||||
"SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
|
"SELECT user_id, revoked FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
|
||||||
(token_hash, time.time())
|
(token_hash, time.time())
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not token_row or token_row["revoked"]:
|
||||||
raise HTTPException(401, "Refresh token истёк или недействителен")
|
raise HTTPException(401, "Refresh token истёк или недействителен")
|
||||||
|
|
||||||
user_row = conn.execute(
|
user = conn.execute(
|
||||||
"SELECT id, username, uuid FROM users WHERE id = ?",
|
"SELECT id, username, uuid, role FROM users WHERE id = ? AND is_active = 1",
|
||||||
(row["user_id"],)
|
(token_row["user_id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not user_row:
|
if not user:
|
||||||
raise HTTPException(401, "Пользователь не найден")
|
raise HTTPException(401, "Пользователь не найден или заблокирован")
|
||||||
|
|
||||||
return _issue_tokens(conn, user_row["id"], user_row["username"], user_row["uuid"])
|
now = time.time()
|
||||||
finally:
|
session_token = secrets.token_urlsafe(32)
|
||||||
conn.close()
|
conn.execute(
|
||||||
|
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/logout")
|
new_access_token = create_jwt({
|
||||||
async def logout(body: dict):
|
"sub": user["id"],
|
||||||
refresh_token = body.get("refresh_token")
|
"username": user["username"],
|
||||||
if refresh_token:
|
"uuid": user["uuid"],
|
||||||
conn = get_db()
|
"role": user["role"],
|
||||||
try:
|
"type": "access",
|
||||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
"jti": session_token
|
||||||
conn.execute(
|
})
|
||||||
"DELETE FROM refresh_tokens WHERE token_hash = ?",
|
|
||||||
(token_hash,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
# ====================== ПРОХОДКИ ======================
|
new_refresh_token = create_jwt({
|
||||||
|
"sub": user["id"],
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": secrets.token_hex(16)
|
||||||
|
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
|
||||||
|
|
||||||
class ActivatePassRequest(BaseModel):
|
new_refresh_hash = hashlib.sha256(new_refresh_token.encode()).hexdigest()
|
||||||
pass_code: str = Field(..., min_length=8, max_length=20)
|
conn.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user["id"], new_refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/pass/activate")
|
# Revoke old refresh token
|
||||||
async def activate_pass_endpoint(
|
conn.execute(
|
||||||
body: ActivatePassRequest,
|
"UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?",
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(bearer)
|
(token_hash,)
|
||||||
):
|
)
|
||||||
|
|
||||||
|
uid = user["id"]
|
||||||
|
uname = user["username"]
|
||||||
|
urole = user["role"]
|
||||||
|
uuuid = user["uuid"]
|
||||||
|
|
||||||
|
log_audit(uid, "refresh_token", f"Token refreshed from {ip}", ip)
|
||||||
|
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
return {
|
||||||
|
"access_token": new_access_token,
|
||||||
|
"refresh_token": new_refresh_token,
|
||||||
|
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
"username": uname,
|
||||||
|
"uuid": uuuid,
|
||||||
|
"role": urole,
|
||||||
|
"role_name": ROLE_NAMES.get(urole, "Неизвестно"),
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.post("/validate")
|
||||||
|
async def validate_token(request: Request, credentials: HTTPAuthorizationCredentials = Depends(bearer)):
|
||||||
|
"""Validate token endpoint for Minecraft server — checks ban status"""
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(401, "Требуется авторизация")
|
raise HTTPException(401, "Требуется авторизация")
|
||||||
|
|
||||||
@@ -326,102 +641,132 @@ async def activate_pass_endpoint(
|
|||||||
if not payload or payload.get("type") != "access":
|
if not payload or payload.get("type") != "access":
|
||||||
raise HTTPException(401, "Недействительный токен")
|
raise HTTPException(401, "Недействительный токен")
|
||||||
|
|
||||||
user_id = payload["sub"]
|
|
||||||
username = payload["username"]
|
|
||||||
pass_code = body.pass_code.upper().strip()
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
try:
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
username = body.get("username")
|
||||||
|
uuid = body.get("uuid")
|
||||||
|
|
||||||
|
# Verify that token belongs to this user
|
||||||
|
if payload.get("username") != username or payload.get("uuid") != uuid:
|
||||||
|
raise HTTPException(403, "Token does not match user")
|
||||||
|
|
||||||
|
# Check ban status in DB
|
||||||
|
with get_db() as conn:
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT is_active, banned_until FROM users WHERE id = ?",
|
||||||
|
(payload["sub"],),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return {"valid": False, "reason": "User not found"}
|
||||||
|
|
||||||
|
if not user["is_active"]:
|
||||||
|
return {"valid": False, "reason": "Account deactivated"}
|
||||||
|
|
||||||
|
if user["banned_until"] and user["banned_until"] > time.time():
|
||||||
|
return {"valid": False, "reason": "Account banned"}
|
||||||
|
|
||||||
|
return {"valid": True, "username": username, "uuid": uuid}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Token validation error: {e}")
|
||||||
|
raise HTTPException(400, "Invalid request")
|
||||||
|
|
||||||
|
|
||||||
|
class ActivatePassRequest(BaseModel):
|
||||||
|
pass_code: str = Field(..., min_length=3, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pass/activate")
|
||||||
|
async def activate_pass(
|
||||||
|
body: ActivatePassRequest,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
request: Request = None,
|
||||||
|
):
|
||||||
|
"""Activate a pass code for the current user"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
# Check if pass exists and is active
|
||||||
pass_row = conn.execute(
|
pass_row = conn.execute(
|
||||||
"SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?",
|
"SELECT code, owner, is_active, expires_at, max_uses, uses, activated_by FROM passes WHERE code = ?",
|
||||||
(pass_code,)
|
(body.pass_code,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not pass_row:
|
if not pass_row:
|
||||||
raise HTTPException(404, "Проходка не найдена")
|
raise HTTPException(404, "Проходка не найдена")
|
||||||
|
|
||||||
# Проверка срока
|
if not pass_row["is_active"]:
|
||||||
if pass_row["expires_at"] and pass_row["expires_at"] < time.time():
|
raise HTTPException(400, "Проходка уже использована или отозвана")
|
||||||
raise HTTPException(410, "Проходка истекла")
|
|
||||||
|
|
||||||
# Проверка лимита использований
|
|
||||||
if pass_row["uses"] >= pass_row["max_uses"]:
|
if pass_row["uses"] >= pass_row["max_uses"]:
|
||||||
raise HTTPException(410, "Проходка уже использована")
|
raise HTTPException(400, "Проходка достигла лимита использований")
|
||||||
|
|
||||||
# Проверка владельца
|
if pass_row["expires_at"] and pass_row["expires_at"] < time.time():
|
||||||
if pass_row["owner"] is not None:
|
raise HTTPException(400, "Проходка истекла")
|
||||||
if pass_row["owner"] != username:
|
|
||||||
raise HTTPException(409, "Проходка уже активирована другим пользователем")
|
|
||||||
|
|
||||||
# Уже активирована этим пользователем
|
# Check if user already has an active pass
|
||||||
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
|
existing = conn.execute(
|
||||||
|
"SELECT 1 FROM user_passes WHERE user_id = ? AND pass_code = ?",
|
||||||
|
(current_user["id"], body.pass_code),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, "Эта проходка уже активирована вами")
|
||||||
|
|
||||||
|
existing_pass = 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()
|
||||||
|
|
||||||
|
if existing_pass:
|
||||||
|
raise HTTPException(409, "У вас уже есть активная проходка")
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
# Активация
|
# Link pass to user
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)",
|
"INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)",
|
||||||
(user_id, pass_code, now)
|
(current_user["id"], body.pass_code, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Increment usage count
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE passes
|
"UPDATE passes SET uses = uses + 1, activated_by = ?, activated_at = ? WHERE code = ?",
|
||||||
SET uses = uses + 1,
|
(current_user["id"], now, body.pass_code),
|
||||||
owner = ?,
|
|
||||||
activated_by = ?,
|
|
||||||
activated_at = ?
|
|
||||||
WHERE code = ?""",
|
|
||||||
(username, user_id, now, pass_code)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
# Upgrade user role if they don't have a higher role
|
||||||
|
if current_user["role"] < 1:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET role = 1 WHERE id = ?",
|
||||||
|
(current_user["id"],),
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Pass activated", user_id=user_id, username=username, pass_code=pass_code)
|
uid = current_user["id"]
|
||||||
return {"success": True, "message": "Проходка успешно активирована!"}
|
uname = current_user["username"]
|
||||||
|
pcode = body.pass_code
|
||||||
|
|
||||||
except HTTPException:
|
log_audit(
|
||||||
raise
|
uid,
|
||||||
except Exception as e:
|
"pass_activated",
|
||||||
logger.error("Pass activation error", exc_info=True)
|
f"Pass activated: {pcode[:8]}...",
|
||||||
raise HTTPException(500, f"Ошибка сервера: {str(e)}")
|
ip,
|
||||||
finally:
|
)
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@router.get("/pass/my")
|
logger.info(
|
||||||
async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)):
|
"Pass activated",
|
||||||
if not credentials:
|
user=uname,
|
||||||
raise HTTPException(401, "Требуется авторизация")
|
user_id=uid,
|
||||||
|
pass_code=pcode,
|
||||||
|
ip=ip,
|
||||||
|
)
|
||||||
|
|
||||||
payload = verify_jwt(credentials.credentials)
|
return {
|
||||||
if not payload:
|
"success": True,
|
||||||
raise HTTPException(401, "Недействительный токен")
|
"message": f"Проходка активирована для {uname}",
|
||||||
|
"role": 1,
|
||||||
user_id = payload["sub"]
|
}
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
try:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT p.code, p.expires_at, p.is_active, up.activated_at
|
|
||||||
FROM user_passes up
|
|
||||||
JOIN passes p ON up.pass_code = p.code
|
|
||||||
WHERE up.user_id = ?
|
|
||||||
""", (user_id,)).fetchall()
|
|
||||||
|
|
||||||
passes = []
|
|
||||||
now = time.time()
|
|
||||||
for row in rows:
|
|
||||||
expires = row["expires_at"]
|
|
||||||
is_active = row["is_active"] and (expires is None or expires > now)
|
|
||||||
passes.append({
|
|
||||||
"code": row["code"],
|
|
||||||
"activated_at": row["activated_at"],
|
|
||||||
"expires_at": expires,
|
|
||||||
"is_active": is_active
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"passes": passes,
|
|
||||||
"has_active": any(p["is_active"] for p in passes)
|
|
||||||
}
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ def parse_args():
|
|||||||
# Additional options
|
# Additional options
|
||||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
|
||||||
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
parser.add_argument("--port", type=int, default=1582, help="Port to bind to (default: 1582)")
|
||||||
parser.add_argument("--workers", type=int, default=4, help="Number of workers for production mode")
|
parser.add_argument("--workers", type=int, default=1, help="Number of workers for production mode (default: 1, more causes file download slowdown)")
|
||||||
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (development)")
|
||||||
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
+1002
-70
File diff suppressed because it is too large
Load Diff
+176
-17
@@ -5,43 +5,202 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import traceback
|
import traceback
|
||||||
|
import httpx
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Public blocklist URLs
|
||||||
|
BLOCKLIST_URLS = [
|
||||||
|
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset",
|
||||||
|
"https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/iblocklist_isp.netset",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_blocklist_from_url(url: str, timeout: int = 10) -> set[str]:
|
||||||
|
"""Download and parse IP blocklist from URL"""
|
||||||
|
ips = set()
|
||||||
|
try:
|
||||||
|
response = httpx.get(url, timeout=timeout, follow_redirects=True)
|
||||||
|
if response.status_code == 200:
|
||||||
|
for line in response.text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if re.match(r"^\d+\.\d+\.\d+\.\d+(/\d+)?$", line):
|
||||||
|
ip = line.split("/")[0]
|
||||||
|
ips.add(ip)
|
||||||
|
logger.info(f"Loaded {len(ips)} IPs from blocklist: {url}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load blocklist from {url}: {e}")
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def load_public_blocklists() -> set[str]:
|
||||||
|
"""Load all public blocklists"""
|
||||||
|
all_ips = set()
|
||||||
|
for url in BLOCKLIST_URLS:
|
||||||
|
all_ips.update(load_blocklist_from_url(url))
|
||||||
|
logger.info(f"Total blocked IPs from public lists: {len(all_ips)}")
|
||||||
|
return all_ips
|
||||||
|
|
||||||
|
|
||||||
|
# Rate limiting config
|
||||||
|
RATE_LIMIT_REQUESTS = 60 # Max requests per window
|
||||||
|
RATE_LIMIT_WINDOW = 60 # Window in seconds
|
||||||
|
_ip_request_counts: dict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
# IP blocking config (set from main.py)
|
||||||
|
BLOCKED_IPS: set[str] = set()
|
||||||
|
|
||||||
|
# Request stats (for summary logging)
|
||||||
|
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||||
|
_stats_last_log = time.time()
|
||||||
|
STATS_LOG_INTERVAL = 60 # Log stats every 60 seconds
|
||||||
|
|
||||||
|
# Suspicious paths that indicate bot scanning
|
||||||
|
SUSPICIOUS_PATHS = {
|
||||||
|
".env", ".env.local", ".env.production", ".env.development", ".env.bak",
|
||||||
|
".env.old", ".env.backup", ".env.orig", ".env.save", ".env~", ".env.swp",
|
||||||
|
".env.copy", ".env.1", ".ENV",
|
||||||
|
"appsettings.json", "appsettings.Development.json", "appsettings.Production.json",
|
||||||
|
"appsettings.Staging.json", "web.config",
|
||||||
|
"phpinfo.php", "info.php", "test.php", "i.php", "phpi.php", "php.php",
|
||||||
|
"phptest.php", "server-info.php", "phpinformation.php", "infophp.php",
|
||||||
|
"php_info.php", "config.php",
|
||||||
|
"actuator/env", "actuator/configprops", "actuator",
|
||||||
|
"manage/env", "admin/env", "env",
|
||||||
|
"actuator/env/aws", "actuator/env/cloud",
|
||||||
|
"_layouts/15/", "_layouts/15/ToolPane.aspx",
|
||||||
|
"wp-admin", "wp-login.php", "wordpress",
|
||||||
|
"administrator", "phpmyadmin",
|
||||||
|
".git", ".svn", ".hg",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract client IP from request"""
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
client_ip = forwarded.split(",")[0].strip()
|
||||||
|
return client_ip
|
||||||
|
|
||||||
|
|
||||||
|
def is_ip_blocked(client_ip: str) -> bool:
|
||||||
|
"""Check if IP is blocked"""
|
||||||
|
return client_ip in BLOCKED_IPS
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(client_ip: str) -> bool:
|
||||||
|
"""Check if IP has exceeded rate limit"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Clean old requests
|
||||||
|
_ip_request_counts[client_ip] = [
|
||||||
|
t for t in _ip_request_counts[client_ip]
|
||||||
|
if now - t < RATE_LIMIT_WINDOW
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(_ip_request_counts[client_ip]) >= RATE_LIMIT_REQUESTS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
_ip_request_counts[client_ip].append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def is_suspicious_path(path: str) -> bool:
|
||||||
|
"""Check if path is suspicious (bot scanning)"""
|
||||||
|
path_lower = path.lower()
|
||||||
|
|
||||||
|
# Direct match
|
||||||
|
if path_lower in SUSPICIOUS_PATHS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Contains suspicious patterns
|
||||||
|
suspicious_patterns = [
|
||||||
|
".env", "phpinfo", "actuator", "wp-", "phpmyadmin",
|
||||||
|
".git", ".svn",
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in suspicious_patterns:
|
||||||
|
if pattern in path_lower:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Path traversal attempts
|
||||||
|
if ".." in path or ".." in path.replace("%2e%2e", "").replace("%252e", ""):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_ip_config(blocked: Optional[set[str]] = None):
|
||||||
|
"""Configure IP blocking (call from main.py)"""
|
||||||
|
global BLOCKED_IPS
|
||||||
|
if blocked is not None:
|
||||||
|
BLOCKED_IPS = blocked
|
||||||
|
|
||||||
|
|
||||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
# Generate request ID
|
|
||||||
request_id = str(uuid.uuid4())[:8]
|
request_id = str(uuid.uuid4())[:8]
|
||||||
|
global _stats, _stats_last_log
|
||||||
|
|
||||||
# Get client IP
|
client_ip = get_client_ip(request)
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
forwarded = request.headers.get("x-forwarded-for")
|
|
||||||
if forwarded:
|
|
||||||
client_ip = forwarded.split(",")[0].strip()
|
|
||||||
|
|
||||||
# Log incoming request
|
# Check if IP is blocked (silent)
|
||||||
logger.info(f"→ {request.method} {request.url.path} (IP: {client_ip}, ID: {request_id})")
|
if is_ip_blocked(client_ip):
|
||||||
|
_stats["blocked"] += 1
|
||||||
|
return Response(status_code=404, content="")
|
||||||
|
|
||||||
# Start timer
|
# Check rate limit
|
||||||
|
if not check_rate_limit(client_ip):
|
||||||
|
_stats["rate_limited"] += 1
|
||||||
|
# Periodic stats logging instead of every warning
|
||||||
|
if time.time() - _stats_last_log > STATS_LOG_INTERVAL:
|
||||||
|
logger.warning(f"Stats: {_stats}")
|
||||||
|
_stats_last_log = time.time()
|
||||||
|
return Response(status_code=429, content="Too many requests")
|
||||||
|
|
||||||
|
# Check suspicious path (silent 404 for bots)
|
||||||
|
path = request.url.path
|
||||||
|
if is_suspicious_path(path):
|
||||||
|
# Return 404 without logging - confuse the bots
|
||||||
|
return Response(status_code=404, content="")
|
||||||
|
|
||||||
|
# Skip logging for large file downloads (don't spam logs)
|
||||||
|
is_file_download = path.startswith("/pack/") and "/file/" in path
|
||||||
|
|
||||||
|
# Track total requests for stats
|
||||||
|
_stats["total"] += 1
|
||||||
|
|
||||||
|
# Log legitimate requests (except file downloads)
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
if not is_file_download:
|
||||||
|
logger.info(f"→ {request.method} {path} (IP: {client_ip}, ID: {request_id})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
||||||
# Calculate duration
|
|
||||||
duration = (time.time() - start_time) * 1000
|
duration = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
# Log response
|
if not is_file_download:
|
||||||
logger.info(f"← {request.method} {request.url.path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
logger.info(f"← {request.method} {path} → {response.status_code} ({duration:.0f}ms) [ID: {request_id}]")
|
||||||
|
|
||||||
|
# Periodic stats logging (only log if there were blocked/rate-limited)
|
||||||
|
now = time.time()
|
||||||
|
if now - _stats_last_log > STATS_LOG_INTERVAL:
|
||||||
|
if _stats["blocked"] > 0 or _stats["rate_limited"] > 0:
|
||||||
|
logger.warning(f"Blocked requests: IP_blocked={_stats['blocked']}, rate_limited={_stats['rate_limited']}")
|
||||||
|
_stats = {"blocked": 0, "rate_limited": 0, "total": 0}
|
||||||
|
_stats_last_log = now
|
||||||
|
|
||||||
# Add request ID to response headers
|
|
||||||
response.headers["X-Request-ID"] = request_id
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = (time.time() - start_time) * 1000
|
duration = (time.time() - start_time) * 1000
|
||||||
# Log full traceback
|
|
||||||
error_traceback = traceback.format_exc()
|
error_traceback = traceback.format_exc()
|
||||||
logger.error(f"✗ {request.method} {request.url.path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
logger.error(f"✗ {request.method} {path} → ERROR: {str(e)} (ID: {request_id})\n{error_traceback}")
|
||||||
raise
|
raise
|
||||||
+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