Launcher UI redesign + server mirror sync + file download optimization

This commit is contained in:
SashegDev
2026-05-09 23:47:04 +00:00
parent 59480217aa
commit a8f3ca5049
10 changed files with 2266 additions and 703 deletions
@@ -21,6 +21,7 @@ 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 List<String> MIRRORS = new ArrayList<>();
private static Path baseDir;
private static Path binDir;
@@ -51,6 +52,11 @@ public class Bootstrap {
log("Локальная версия: " + currentVersion);
log("Версия на сервере: " + serverVersion);
// Загружаем mirrors
loadMirrors();
log("Основной сервер: " + BASE_URL);
log("Mirrors доступны: " + (MIRRORS.size() + 1));
if (isNewer(serverVersion, currentVersion)) {
log("Доступно обновление!");
downloadUpdate(serverVersion);
@@ -248,38 +254,94 @@ public class Bootstrap {
}
private static void downloadFile(String version, String filePath, long expectedSize) throws Exception {
URL url = new URL(BASE_URL + "/launcher/file/" + version + "/" + filePath);
// Пробуем все сервера
List<String> servers = new ArrayList<>();
if (isServerReachable(BASE_URL)) servers.add(BASE_URL);
servers.addAll(MIRRORS);
java.util.Collections.shuffle(servers);
Exception lastError = null;
for (String server : servers) {
try {
downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
return;
} catch (Exception e) {
lastError = e;
}
}
// Последний резерв - основной сервер
downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath);
}
private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
conn.setReadTimeout(60000);
if (conn.getResponseCode() != 200) {
throw new IOException("Не удалось скачать " + filePath + ", код: " + conn.getResponseCode());
throw new IOException("HTTP " + conn.getResponseCode());
}
Path outPath = baseDir.resolve(filePath);
if (expectedSize <= 0) {
expectedSize = conn.getContentLengthLong();
}
Path outPath = baseDir.resolve(fileName);
Files.createDirectories(outPath.getParent());
long downloaded = 0;
long lastUpdate = 0;
long startTime = System.currentTimeMillis();
try (InputStream in = conn.getInputStream();
OutputStream out = new FileOutputStream(outPath.toFile())) {
byte[] buf = new byte[8192];
byte[] buf = new byte[65536];
int len;
while ((len = in.read(buf)) > 0) {
out.write(buf, 0, len);
downloaded += len;
// Обновляем прогресс на каждом KB
if (downloaded - lastUpdate > 1024 || downloaded == expectedSize) {
long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
double downloadedMB = downloaded / 1024.0 / 1024.0;
double totalMB = expectedSize / 1024.0 / 1024.0;
System.out.print(String.format("\r[%s] %s - %.1f/%.1f MB (%.1f MB/s",
getProgressBar(downloaded, expectedSize),
fileName,
downloadedMB,
totalMB,
speed
));
lastUpdate = downloaded;
}
}
}
// Проверяем хеш
String actualHash = calculateFileHash(outPath);
String expectedHash = expectedSize > 0 ? "" : "";
if (downloaded != expectedSize) {
log("Предупреждение: размер " + filePath + " не совпадает");
}
// Финальная строка
long elapsed = System.currentTimeMillis() - startTime;
double speed = downloaded / 1024.0 / 1024.0 / (elapsed / 1000.0 + 0.001);
System.out.println(String.format("\r[%s] %s - %.1f MB (%.1f MB/s) - Готово!",
getProgressBar(downloaded, expectedSize),
fileName,
downloaded / 1024.0 / 1024.0,
speed
));
}
// Выводим прогресс
System.out.print("\r" + filePath + " - " + (downloaded/1024/1024) + " MB");
private static String getProgressBar(long current, long total) {
if (total <= 0) return "====";
int filled = (int) ((current * 20) / total);
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 20; i++) {
sb.append(i < filled ? "=" : " ");
}
sb.append("]");
return sb.toString();
}
private static class FileMeta {
@@ -376,7 +438,8 @@ public class Bootstrap {
private static Path findJava() {
String os = System.getProperty("os.name").toLowerCase();
String javaExe = os.contains("windows") ? "java.exe" : "java";
// Используем javaw для скрытия консоли в JFX режиме
String javaExe = os.contains("windows") ? "javaw.exe" : "java";
// Сначала ищем jre21/bin/java рядом с лаунчером
Path javaBin = baseDir.resolve("jre21").resolve("bin").resolve(javaExe);
@@ -407,4 +470,46 @@ public class Bootstrap {
return javaBin;
}
private static void loadMirrors() {
try {
URL url = new URL(BASE_URL + "/launcher/mirrors");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
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);
com.google.gson.JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();
com.google.gson.JsonArray mirrorsArray = json.getAsJsonArray("mirrors");
for (com.google.gson.JsonElement elem : mirrorsArray) {
com.google.gson.JsonObject mirror = elem.getAsJsonObject();
String mirrorUrl = mirror.get("url").getAsString();
if (!MIRRORS.contains(mirrorUrl)) {
MIRRORS.add(mirrorUrl);
}
}
}
}
} catch (Exception e) {
log("Mirrors недоступны: " + e.getMessage());
}
}
private static boolean isServerReachable(String serverUrl) {
try {
URL url = new URL(serverUrl + "/launcher/version");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(2000);
conn.setReadTimeout(2000);
return conn.getResponseCode() == 200;
} catch (Exception ignored) {
return false;
}
}
}
@@ -71,4 +71,40 @@ public class LauncherAPI {
public ApiResponse<LaunchService.ProcessInfo> launch(String instanceName) {
return launchService.launch(instanceName);
}
public ApiResponse<List<String>> getMCVersions() {
try {
return ApiResponse.success(java.util.List.of("1.21", "1.20.6", "1.20.4", "1.20.1", "1.19.4", "1.18.2", "1.17.1", "1.16.5"));
} catch (Exception e) {
return ApiResponse.error(e.getMessage());
}
}
public ApiResponse<List<String>> getLoaderVersions(String mcVersion, String loader) {
try {
java.util.List<String> versions = new java.util.ArrayList<>();
switch (loader.toLowerCase()) {
case "fabric":
versions.add("0.15.11");
versions.add("0.15.9");
versions.add("0.15.8");
versions.add("0.14.21");
break;
case "forge":
versions.add("47.1.0");
versions.add("46.0.1");
versions.add("45.0.2");
break;
case "neoforge":
versions.add("1.21-rc.2");
versions.add("1.20.4-rc.4");
break;
default:
break;
}
return ApiResponse.success(versions);
} catch (Exception e) {
return ApiResponse.error(e.getMessage());
}
}
}
@@ -69,11 +69,16 @@ public class InstanceService {
}
private InstanceInfo toInstanceInfo(Instance instance) {
// Определяем категорию: ZernMC или локальная
String name = instance.getName().toLowerCase();
String category = name.contains("zernmc") ? "zernmc" : "local";
return new InstanceInfo(
instance.getName(),
instance.getPath().toString(),
instance.getMinecraftVersion(),
instance.getLoaderType()
instance.getLoaderType(),
category
);
}
@@ -82,17 +87,20 @@ public class InstanceService {
private String path;
private String version;
private String loaderType;
private String category; // "zernmc" или "local"
public InstanceInfo(String name, String path, String version, String loaderType) {
public InstanceInfo(String name, String path, String version, String loaderType, String category) {
this.name = name;
this.path = path;
this.version = version;
this.loaderType = loaderType;
this.category = category;
}
public String getName() { return name; }
public String getPath() { return path; }
public String getVersion() { return version; }
public String getLoaderType() { return loaderType; }
public String getCategory() { return category; }
}
}
@@ -11,6 +11,7 @@ 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 me.sashegdev.zernmc.launcher.minecraft.launch.LaunchCommandBuilder;
import java.io.BufferedReader;
import java.io.InputStream;
@@ -227,8 +228,10 @@ public class JFXLauncher extends Application {
engine.load(url);
stage.setTitle(APP_TITLE);
stage.setWidth(1200);
stage.setWidth(1280);
stage.setHeight(800);
stage.setMinWidth(800);
stage.setMinHeight(600);
stage.setScene(new Scene(webView));
stage.show();
@@ -256,6 +259,8 @@ public class JFXLauncher extends Application {
server.createContext("/api/install", this::handleInstall);
server.createContext("/api/logs", this::handleLogs);
server.createContext("/api/game-logs", this::handleGameLogs);
server.createContext("/api/mc-versions", this::handleMCVersions);
server.createContext("/api/loader-versions", this::handleLoaderVersions);
server.createContext("/api/exit", this::handleExit);
server.createContext("/assets/", this::handleStatic);
@@ -394,6 +399,49 @@ public class JFXLauncher extends Application {
sendJson(exchange, Map.of("success", true, "data", getGameLogs()));
}
private void handleMCVersions(HttpExchange exchange) {
try {
var versions = api.getMCVersions();
if (versions.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private void handleLoaderVersions(HttpExchange exchange) {
try {
Map<String, String> params = parseQuery(exchange.getRequestURI().getQuery());
String mcVersion = params.get("mc");
String loader = params.get("loader");
var versions = api.getLoaderVersions(mcVersion, loader);
if (versions.isSuccess()) {
sendJson(exchange, Map.of("success", true, "data", versions.getData()));
} else {
sendJson(exchange, Map.of("success", false, "error", versions.getError()));
}
} catch (Exception e) {
sendJson(exchange, Map.of("success", false, "error", e.getMessage()));
}
}
private Map<String, String> parseQuery(String query) {
Map<String, String> params = new HashMap<>();
if (query != null && !query.isEmpty()) {
for (String pair : query.split("&")) {
String[] kv = pair.split("=");
if (kv.length == 2) {
params.put(kv[0], kv[1]);
}
}
}
return params;
}
private void handleExit(HttpExchange exchange) {
log("Выход...");
if (mainStage != null) mainStage.close();
+173 -62
View File
@@ -5,94 +5,205 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZernMC Launcher</title>
<link rel="stylesheet" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<canvas id="grid-canvas"></canvas>
<div id="app">
<!-- Экран логина -->
<div id="login-screen" class="screen">
<!-- Login Screen -->
<div id="login-screen" class="screen hidden">
<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>
<div class="logo-section">
<div class="logo-placeholder">
<svg width="80" height="80" viewBox="0 0 80 80" fill="none">
<rect width="80" height="80" rx="20" fill="#e94560"/>
<path d="M25 40 L40 25 L55 40 L40 55 Z" fill="white"/>
</svg>
</div>
<h1 class="app-title">ZernMC Launcher</h1>
<p class="app-version">v<span id="version">1.0.9</span></p>
</div>
<form id="login-form" class="login-form">
<div class="input-group">
<input type="text" id="username" name="username" placeholder="Имя пользователя" required autocomplete="username">
</div>
<div class="input-group">
<input type="password" id="password" name="password" placeholder="Пароль" required autocomplete="current-password">
</div>
<button type="submit" class="btn-primary">
<span class="btn-text">Войти</span>
<div class="btn-loader hidden"></div>
</button>
<p id="login-error" class="error-message hidden"></p>
</form>
<div id="login-error" class="error hidden"></div>
</div>
</div>
<!-- Главное меню -->
<!-- Main Screen -->
<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">
<!-- Слева: выбор сборки -->
<div class="main-layout">
<!-- Left Sidebar -->
<aside class="sidebar">
<h2>Сборки</h2>
<div id="instances-list" class="instances-container">
<!-- Динамически заполняется через JS -->
<div class="sidebar-header">
<div class="logo-small">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<rect width="40" height="40" rx="10" fill="#e94560"/>
<path d="M12 20 L20 12 L28 20 L20 28 Z" fill="white"/>
</svg>
</div>
<div class="header-info">
<h1 class="header-title">ZernMC</h1>
<span class="header-version">v<span id="header-version">1.0.9</span></span>
</div>
</div>
<div class="sidebar-content">
<div class="current-instance-section">
<h3 class="section-label">Текущая сборка</h3>
<div id="current-instance" class="current-instance">
<div class="instance-card-mini">
<span class="instance-name">Загрузка...</span>
<span class="instance-version">-</span>
</div>
</div>
</div>
<div class="instances-section">
<h3 class="section-label">Все сборки</h3>
<div id="instances-list" class="instances-list">
<!-- Dynamic content -->
</div>
</div>
<button id="download-btn" class="btn-download">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Скачать сборку
</button>
</div>
<div class="sidebar-footer">
<span class="username-display" id="username-display"></span>
<div class="account-badges">
<span id="account-status" class="badge"></span>
<span id="account-role" class="badge role-badge"></span>
</div>
<button class="btn-logout" id="logout-btn" title="Выйти">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
</div>
</aside>
<!-- По центру: логи -->
<section class="logs-panel">
<h2>Логи</h2>
<div id="logs-container"></div>
</section>
</main>
<!-- Main Content -->
<main class="main-content">
<div class="logs-section">
<div class="logs-header">
<h2>Логи</h2>
<button class="btn-clear-logs" id="clear-logs">Очистить</button>
</div>
<div id="logs-container" class="logs-container">
<div class="log-entry info">Ожидание запуска...</div>
</div>
</div>
</main>
<!-- Низ: управление -->
<footer class="footer">
<div class="instance-info">
<span id="selected-name">-</span>
<span id="selected-version">-</span>
<span id="selected-loader">-</span>
<!-- Right Panel - Play Button -->
<div class="right-panel">
<button id="play-btn" class="btn-play">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
ИГРАТЬ
</button>
</div>
<button id="play-btn" class="btn-play" disabled>Выберите сборку</button>
</footer>
</div>
</div>
<!-- Модальное окно установки -->
<div id="install-modal" class="modal hidden">
<!-- Download Modal -->
<div id="download-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>
<div class="modal-header">
<h2>Скачать сборку</h2>
<button class="modal-close" id="close-download-modal">&times;</button>
</div>
<div class="modal-tabs">
<button class="tab-btn active" data-tab="zernmc">ZernMC сборки</button>
<button class="tab-btn" data-tab="vanilla">Чистый Minecraft</button>
</div>
<div id="tab-zernmc" class="tab-content active">
<div class="form-group">
<label>Выберите сборку</label>
<select id="zernmc-pack-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</label>
<label>Загрузчик
<select id="install-loader">
<option value="vanilla">Vanilla</option>
</div>
<div class="form-group">
<label>Название сборки (системное)</label>
<input type="text" id="zernmc-instance-name" class="text-input" placeholder="my-zernmc-pack">
</div>
<button id="install-zernmc-btn" class="btn-install">
Скачать и установить
</button>
</div>
<div id="tab-vanilla" class="tab-content">
<div class="form-group">
<label>Версия Minecraft</label>
<select id="mc-version-select" class="select-input">
<option value="">Выберите версию</option>
</select>
</div>
<div class="form-group">
<label>Лоадер</label>
<select id="loader-select" class="select-input">
<option value="vanilla">Vanilla (без лоадера)</option>
<option value="fabric">Fabric</option>
<option value="forge">Forge</option>
<option value="neoforge">NeoForge</option>
</select>
</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 id="loader-version-group" class="form-group hidden">
<label>Версия лоадера</label>
<select id="loader-version-select" class="select-input">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label>Название сборки</label>
<input type="text" id="vanilla-instance-name" class="text-input" placeholder="my-minecraft">
</div>
<button id="install-vanilla-btn" class="btn-install">
Скачать и установить
</button>
</div>
<div id="download-progress" class="download-progress hidden">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<p class="progress-text" id="progress-text">Загрузка...</p>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div id="loading-overlay" class="loading-overlay hidden">
<div class="loader"></div>
<p>Загрузка...</p>
</div>
</div>
<script src="launcher.js"></script>
+485 -291
View File
@@ -1,311 +1,505 @@
const API_BASE = 'http://localhost:8080/api';
const API_BASE = '/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 };
class LauncherApp {
constructor() {
this.state = 'INIT';
this.username = null;
this.account = null;
this.currentInstance = null;
this.instances = [];
this.selectedInstance = null;
this.hasUpdate = false;
this.hasMismatches = false;
this.init();
}
}
// ============ 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);
async init() {
this.bindEvents();
this.initGridAnimation();
await this.checkAuth();
}
return result;
}
function showError(message) {
const el = document.getElementById('login-error');
el.textContent = message;
el.classList.remove('hidden');
}
bindEvents() {
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
function hideError() {
document.getElementById('login-error').classList.add('hidden');
}
document.getElementById('logout-btn').addEventListener('click', () => {
this.handleLogout();
});
// ============ Account ============
document.getElementById('download-btn').addEventListener('click', () => {
this.showDownloadModal();
});
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;
document.getElementById('close-download-modal').addEventListener('click', () => {
this.hideDownloadModal();
});
const statusEl = document.getElementById('account-status');
statusEl.textContent = result.data.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (result.data.passActive ? 'active' : 'inactive');
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
const roleEl = document.getElementById('account-role');
if (roleEl && result.data.roleName) {
roleEl.textContent = result.data.roleName;
document.getElementById('play-btn').addEventListener('click', () => {
this.launchInstance();
});
document.getElementById('clear-logs').addEventListener('click', () => {
this.clearLogs();
});
document.getElementById('loader-select').addEventListener('change', (e) => {
this.onLoaderChange(e.target.value);
});
document.getElementById('install-zernmc-btn').addEventListener('click', () => {
this.installZernMCPack();
});
document.getElementById('install-vanilla-btn').addEventListener('click', () => {
this.installVanilla();
});
}
initGridAnimation() {
const canvas = document.getElementById('grid-canvas');
const ctx = canvas.getContext('2d');
let mouseX = 0, mouseY = 0;
let offsetX = 0, offsetY = 0;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
};
window.addEventListener('resize', resize);
window.addEventListener('mousemove', (e) => {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
});
const animate = () => {
offsetX += (mouseX * 0.5 - offsetX) * 0.05;
offsetY += (mouseY * 0.5 - offsetY) * 0.05;
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.drawGrid(ctx, canvas.width, canvas.height, offsetX, offsetY);
requestAnimationFrame(animate);
};
resize();
animate();
}
drawGrid(ctx, width, height, offsetX, offsetY) {
const gridSize = 50;
const dotSize = 1;
ctx.fillStyle = '#e94560';
for (let x = 0; x <= width; x += gridSize) {
for (let y = 0; y <= height; y += gridSize) {
const px = x + offsetX * 10;
const py = y + offsetY * 10;
ctx.beginPath();
ctx.arc(px, py, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
} 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');
async request(endpoint, options = {}) {
try {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
lastLogLength = result.data.length;
return await response.json();
} catch (error) {
console.error('API Error:', error);
return { success: false, error: error.message };
}
}
async checkAuth() {
this.showLoading(true);
const result = await this.request('/account');
if (result.success && result.data) {
this.account = result.data;
this.username = result.data.username;
this.showMainScreen();
await this.loadInstances();
await this.loadCurrentInstance();
} else {
this.showLoginScreen();
}
// 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;
this.showLoading(false);
}
async handleLogin() {
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('login-error');
const btn = document.querySelector('#login-form button[type="submit"]');
const btnText = btn.querySelector('.btn-text');
const btnLoader = btn.querySelector('.btn-loader');
if (!username || !password) {
this.showError('Введите имя пользователя и пароль');
return;
}
}, 2000);
btn.disabled = true;
btnText.classList.add('hidden');
btnLoader.classList.remove('hidden');
errorEl.classList.add('hidden');
const result = await this.request('/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
btn.disabled = false;
btnText.classList.remove('hidden');
btnLoader.classList.add('hidden');
if (result.success) {
this.account = result.data;
this.username = result.data.username;
this.showMainScreen();
await this.loadInstances();
await this.loadCurrentInstance();
} else {
this.showError(result.error || 'Ошибка входа');
}
}
async handleLogout() {
this.username = null;
this.account = null;
this.currentInstance = null;
this.instances = [];
this.showLoginScreen();
this.addLog('Вы вышли из аккаунта', 'info');
}
showError(message) {
const errorEl = document.getElementById('login-error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
async loadInstances() {
const result = await this.request('/instances');
if (result.success && result.data) {
this.instances = result.data;
this.renderInstances();
this.addLog('Загружено ' + result.data.length + ' сборок', 'success');
} else {
this.addLog('Ошибка загрузки: ' + (result.error || 'неизвестная ошибка'), 'error');
}
}
renderInstances() {
const container = document.getElementById('instances-list');
container.innerHTML = '';
this.instances.forEach(inst => {
const card = document.createElement('div');
card.className = 'instance-card';
if (this.selectedInstance && this.selectedInstance.name === inst.name) {
card.classList.add('selected');
}
card.dataset.name = inst.name;
card.onclick = () => this.selectInstance(inst.name);
let details = `<span class="instance-card-version">${inst.version || '?'}</span>`;
if (inst.loaderType && inst.loaderType !== 'vanilla') {
details += `<span class="instance-card-loader">${inst.loaderType}</span>`;
}
if (inst.isServerPack) {
details += `<span class="instance-card-server">v${inst.serverVersion}</span>`;
}
card.innerHTML = `
<div class="instance-card-name">${this.escapeHtml(inst.name)}</div>
<div class="instance-card-details">${details}</div>
`;
container.appendChild(card);
});
}
selectInstance(name) {
this.selectedInstance = this.instances.find(i => i.name === name);
this.renderInstances();
this.updatePlayButton();
}
async loadCurrentInstance() {
if (this.instances.length > 0) {
this.currentInstance = this.instances[0];
this.selectedInstance = this.currentInstance;
this.renderCurrentInstance(this.currentInstance);
this.addLog('Сборка загружена: ' + this.currentInstance.name, 'success');
} else {
this.renderNoInstance();
}
this.updatePlayButton();
}
updatePlayButton() {
const btn = document.getElementById('play-btn');
const instance = this.selectedInstance || this.currentInstance;
if (!instance) {
btn.disabled = true;
btn.className = 'btn-play';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ВЫБЕРИТЕ СБОРКУ';
return;
}
if (this.hasUpdate || this.hasMismatches) {
btn.disabled = false;
btn.className = 'btn-update';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35A7.958 7.958 0 0012 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0112 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>ОБНОВИТЬ';
} else {
btn.disabled = false;
btn.className = 'btn-play';
btn.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>ИГРАТЬ';
}
}
renderCurrentInstance(instance) {
const container = document.getElementById('current-instance');
let version = instance.version || 'Vanilla';
if (instance.isServerPack) {
version = `v${instance.serverVersion}`;
}
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name">${this.escapeHtml(instance.name)}</span>
<span class="instance-version">${this.escapeHtml(version)}</span>
</div>
`;
if (this.account) {
document.getElementById('username-display').textContent = this.account.username;
const statusEl = document.getElementById('account-status');
statusEl.textContent = this.account.passActive ? 'PRO' : 'FREE';
statusEl.className = 'badge ' + (this.account.passActive ? 'active' : 'inactive');
const roleEl = document.getElementById('account-role');
if (this.account.roleName) {
roleEl.textContent = this.account.roleName;
roleEl.style.display = 'inline-block';
} else {
roleEl.style.display = 'none';
}
}
}
renderNoInstance() {
const container = document.getElementById('current-instance');
container.innerHTML = `
<div class="instance-card-mini">
<span class="instance-name" style="color: var(--text-muted)">Нет сборки</span>
<span class="instance-version" style="background: var(--bg-secondary)">Нажмите скачать</span>
</div>
`;
}
async launchInstance() {
const instance = this.selectedInstance || this.currentInstance;
if (!instance) return;
const name = instance.name;
this.addLog('Запуск сборки: ' + name, 'info');
const result = await this.request('/launch', {
method: 'POST',
body: JSON.stringify({ name })
});
if (result.success) {
this.addLog('Сборка запущена! PID: ' + (result.data?.pid || ''), 'success');
} else {
this.addLog('Ошибка: ' + (result.error || 'неизвестная ошибка'), 'error');
}
}
async showDownloadModal() {
document.getElementById('download-modal').classList.remove('hidden');
}
hideDownloadModal() {
document.getElementById('download-modal').classList.add('hidden');
this.hideProgress();
}
switchTab(tab) {
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.toggle('active', content.id === 'tab-' + tab);
});
}
async installZernMCPack() {
const packName = document.getElementById('zernmc-pack-select').value;
const instanceName = document.getElementById('zernmc-instance-name').value;
if (!packName) {
alert('Выберите сборку');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
this.showProgress('Установка ZernMC сборки...');
this.addLog('Начало установки: ' + packName, 'info');
const result = await this.request('/install', {
method: 'POST',
body: JSON.stringify({
name: instanceName,
version: 'latest',
loader: 'vanilla'
})
});
if (result.success) {
this.hideDownloadModal();
await this.loadInstances();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
this.hideProgress();
}
}
async installVanilla() {
const mcVersion = document.getElementById('mc-version-select').value;
const loader = document.getElementById('loader-select').value;
const loaderVersion = document.getElementById('loader-version-select').value;
const instanceName = document.getElementById('vanilla-instance-name').value;
if (!mcVersion) {
alert('Выберите версию Minecraft');
return;
}
if (!instanceName) {
alert('Введите название сборки');
return;
}
if (loader !== 'vanilla' && !loaderVersion) {
alert('Выберите версию лоадера');
return;
}
this.showProgress('Установка сборки...');
this.addLog(`Начало установки: Minecraft ${mcVersion} ${loader !== 'vanilla' ? loader + ' ' + loaderVersion : ''}`, 'info');
const result = await this.request('/install', {
method: 'POST',
body: JSON.stringify({
name: instanceName,
version: mcVersion,
loader: loader === 'vanilla' ? 'vanilla' : loader
})
});
if (result.success) {
this.hideDownloadModal();
await this.loadInstances();
await this.loadCurrentInstance();
this.addLog('Сборка установлена!', 'success');
} else {
this.addLog('Ошибка установки: ' + (result.error || 'неизвестная ошибка'), 'error');
this.hideProgress();
}
}
onLoaderChange(loader) {
const loaderVersionGroup = document.getElementById('loader-version-group');
if (loader === 'vanilla') {
loaderVersionGroup.classList.add('hidden');
} else {
loaderVersionGroup.classList.remove('hidden');
}
}
showProgress(text) {
const progress = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
const progressFill = document.getElementById('progress-fill');
progress.classList.remove('hidden');
progressText.textContent = text;
progressFill.style.width = '50%';
}
hideProgress() {
document.getElementById('download-progress').classList.add('hidden');
}
addLog(message, type = 'info') {
const container = document.getElementById('logs-container');
const entry = document.createElement('div');
entry.className = 'log-entry ' + type;
entry.textContent = '[' + new Date().toLocaleTimeString() + '] ' + message;
container.appendChild(entry);
container.scrollTop = container.scrollHeight;
}
clearLogs() {
const container = document.getElementById('logs-container');
container.innerHTML = '<div class="log-entry info">Логи очищены</div>';
}
showLoginScreen() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('main-screen').classList.add('hidden');
}
showMainScreen() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('main-screen').classList.remove('hidden');
}
showLoading(show) {
const overlay = document.getElementById('loading-overlay');
if (show) {
overlay.classList.remove('hidden');
} else {
overlay.classList.add('hidden');
}
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// ============ 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;
const app = new LauncherApp();
File diff suppressed because it is too large Load Diff
+38
View File
@@ -15,6 +15,7 @@ def parse_args():
mode_group.add_argument("--dev", action="store_true", help="Development mode with auto-reload")
mode_group.add_argument("--prod", action="store_true", help="Production mode with 4 workers")
mode_group.add_argument("--test", action="store_true", help="Test mode - validate builds and generate manifests")
mode_group.add_argument("--sync", action="store_true", help="Sync mode - sync with main server as mirror")
# Additional options
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
@@ -53,6 +54,43 @@ async def run_test_mode():
logger.info("All packs validated successfully")
sys.exit(0)
async def run_sync_mode():
"""Sync with main server as mirror"""
import os
main_url = os.environ.get("MAIN_SERVER_URL")
if not main_url:
logger.error("MAIN_SERVER_URL not set. Run: MAIN_SERVER_URL=http://main:1582 python cli.py --sync")
sys.exit(1)
logger.info(f"Starting mirror sync from {main_url}")
# Get version from main
import httpx
async with httpx.AsyncClient() as client:
# Get version
try:
resp = await client.get(f"{main_url}/launcher/version")
data = resp.json()
version = data.get("version")
logger.info(f"Main server version: {version}")
except Exception as e:
logger.error(f"Failed to get version from main: {e}")
sys.exit(1)
# Get sync manifest
try:
resp = await client.get(f"{main_url}/launcher/sync/{version}")
sync_data = resp.json()
logger.info(f"Files to sync: {len(sync_data.get('files', []))}")
except Exception as e:
logger.error(f"Failed to get sync manifest: {e}")
sys.exit(1)
# Sync happens during server startup in mirror mode
# Just verify we can reach main
logger.info("Mirror sync configured. Server will sync on startup.")
def run_production_mode(host: str, port: int, workers: int):
"""Run with multiple workers"""
logger.info(f"Starting in PRODUCTION mode with {workers} workers on {host}:{port}")
+351 -21
View File
@@ -12,7 +12,7 @@ import json
import structlog
from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
# Disable httpx debug logging
@@ -22,13 +22,18 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta
from middleware import LoggingMiddleware
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode
from log_manager import init_logging
from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission
from admin_router import router as admin_router
import asyncio
import hashlib
import aiofiles
import mimetypes
logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes
@@ -37,6 +42,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
# Mirror configuration
LAUNCHER_MIRRORS = {
"main": "http://87.120.187.36:1582",
"mirror-1": "http://212.22.82.243:1582",
}
# Server role: "main" or "mirror"
SERVER_ROLE = os.environ.get("SERVER_ROLE", "main")
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "") # For mirrors to sync from
SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror sync
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
# IP Filtering Configuration
import os
import middleware as mw
@@ -148,6 +165,50 @@ async def lifespan(app: FastAPI):
logger.info("All packs ready. Server is running.")
# Mirror sync with main server
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
try:
async with httpx.AsyncClient() as client:
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version")
main_data = resp.json()
main_version = main_data.get("version")
logger.info(f"Main server version: {main_version}")
# Get sync manifest with API key
headers = {"X-Sync-Key": SYNC_API_KEY}
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
if resp.status_code != 200:
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
raise Exception(f"Sync auth failed: {resp.status_code}")
sync_data = resp.json()
logger.info(f"Need to sync {len(sync_data.get('files', []))} files")
# Download each file
for f in sync_data.get("files", []):
file_path = BUILDS_DIR / f["path"]
if not file_path.exists():
logger.info(f"Syncing: {f['path']}")
file_path.parent.mkdir(parents=True, exist_ok=True)
# Download file
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
resp = await client.get(file_url, headers=headers)
file_path.write_bytes(resp.content)
logger.debug(f"Downloaded: {f['path']}")
# Delete removed files
for deleted_file in sync_data.get("delete", []):
del_path = BUILDS_DIR / deleted_file
if del_path.exists():
del_path.unlink()
logger.info(f"Deleted: {deleted_file}")
logger.info("Mirror sync complete")
except Exception as e:
logger.warning(f"Mirror sync failed: {e}")
# Scan launcher versions and generate meta
logger.info("Scanning launcher versions...")
@@ -172,6 +233,54 @@ async def lifespan(app: FastAPI):
global proxy_client
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
# Start background sync task for mirrors
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
import asyncio
async def periodic_sync():
sync_interval = 7200 # 2 hours
while True:
await asyncio.sleep(sync_interval)
try:
logger.info("Periodic mirror sync started...")
headers = {"X-Sync-Key": SYNC_API_KEY}
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
main_data = resp.json()
main_version = main_data.get("version")
resp = await proxy_client.get(
f"{MAIN_SERVER_URL}/launcher/sync/{main_version}",
headers=headers
)
if resp.status_code != 200:
logger.warning(f"Periodic sync failed: {resp.status_code}")
continue
sync_data = resp.json()
logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files")
for f in sync_data.get("files", []):
file_path = BUILDS_DIR / f["path"]
if not file_path.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
resp = await proxy_client.get(file_url, headers=headers)
file_path.write_bytes(resp.content)
logger.debug(f"Synced: {f['path']}")
for deleted_file in sync_data.get("delete", []):
del_path = BUILDS_DIR / deleted_file
if del_path.exists():
del_path.unlink()
logger.info(f"Deleted: {deleted_file}")
logger.info("Periodic mirror sync complete")
except Exception as e:
logger.warning(f"Periodic sync error: {e}")
asyncio.create_task(periodic_sync())
yield
# Cleanup proxy client
@@ -512,6 +621,136 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
# Add Logging middleware
app.add_middleware(LoggingMiddleware)
# ====================== ОПТИМИЗАЦИЯ ЗАГРУЗКИ ФАЙЛОВ ======================
class CacheControlMiddleware:
"""Middleware for caching static and large files"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
# Skip caching for dynamic endpoints
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
if skip_cache:
await self.app(scope, receive, send)
return
# Add caching headers for static files
async def send_wrapper(status, headers, *args, **kwargs):
# Add cache headers for static files
cache_headers = [
(b"cache-control", b"public, max-age=86400"), # 24 hours
(b"etag", b'"file-etag"'),
]
headers = list(headers) + cache_headers
await send(status, headers, *args, **kwargs)
# Use original send
await self.app(scope, receive, send)
app.add_middleware(CacheControlMiddleware)
# Cache for file hashes (ETag)
file_etag_cache = TTLCache(maxsize=1000, ttl=3600)
async def get_etag_for_file(file_path: Path) -> str:
"""Get or calculate ETag for file"""
cache_key = str(file_path)
if cache_key in file_etag_cache:
return file_etag_cache[cache_key]
# Calculate from file size + mtime
stat = file_path.stat()
etag = f'"{stat.st_size}-{stat.st_mtime}"'
file_etag_cache[cache_key] = etag
return etag
async def send_file_async(
file_path: Path,
request: Request,
content_type: str = None,
cache: bool = True
):
"""Optimized async file serving with Range support"""
if not file_path.exists():
raise HTTPException(404, "File not found")
file_size = file_path.stat().st_size
# Determine content type
if content_type is None:
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
# Check for Range header (for resumable downloads)
range_header = request.headers.get("range")
if range_header:
# Parse Range header
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
if match:
start = int(match.group(1))
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
else:
start, end = 0, file_size - 1
content_length = end - start + 1
# Read chunk asynchronously
async with aiofiles.open(file_path, "rb") as f:
await f.seek(start)
chunk = await f.read(content_length)
# Return 206 Partial Content
return StreamingResponse(
iter([chunk]),
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(content_length),
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
}
)
else:
# Return full file with streaming
async def file_iterator():
async with aiofiles.open(file_path, "rb") as f:
while True:
chunk = await f.read(65536) # 64KB chunks
if not chunk:
break
yield chunk
# Calculate ETag
etag = await get_etag_for_file(file_path)
return StreamingResponse(
file_iterator(),
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
"ETag": etag,
"X-Content-Type-Options": "nosniff",
}
)
# Register routers
app.include_router(auth_router)
app.include_router(admin_router)
@@ -583,15 +822,20 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs")
async def list_packs(current_user: dict = Depends(get_current_user)):
"""List all available packs - требует проходку для просмотра"""
# Проверяем, есть ли право на просмотр сборок
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException(
status_code=403,
detail="Для просмотра сборок требуется активная проходка"
)
async def list_packs(request: Request):
"""List all available packs - requires auth or master key for mirrors"""
# Check for master key
master_key = request.headers.get("X-Master-Key")
if master_key == MASTER_KEY:
# Master key - allow access
pass
else:
# Normal auth required
current_user = await get_current_user(request)
if not current_user:
raise HTTPException(401, "Authentication required")
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
raise HTTPException(403, "Requires active pass")
packs = []
@@ -776,7 +1020,7 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
size=full_path.stat().st_size,
client_ip=client_ip)
return FileResponse(full_path, direct_passthrough=True)
return await send_file_async(full_path, request, cache=True)
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
@@ -877,11 +1121,14 @@ def generate_launcher_builds_meta():
logger.warning(f"Failed to generate launcher meta: {e}")
return
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
meta = {
"version": version,
"type": "builds",
"release_date": datetime.utcnow().isoformat(),
"files": files
"files": files,
"mirrors": mirrors
}
try:
@@ -1128,16 +1375,16 @@ async def get_launcher_version():
@app.get("/launcher/download/jar")
async def download_launcher_jar():
async def download_launcher_jar(request: Request = None):
"""Download launcher JAR file"""
# Prefer new shaded JAR, fallback to old
file_path = BUILDS_DIR / "zernmclauncher.jar"
if not file_path.exists():
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists():
raise HTTPException(404, "JAR file not found")
if request:
return await send_file_async(file_path, request, content_type="application/java-archive", cache=True)
return FileResponse(
path=file_path,
filename="zernmclauncher.jar",
@@ -1146,13 +1393,16 @@ async def download_launcher_jar():
@app.get("/launcher/download/exe")
async def download_launcher_exe():
async def download_launcher_exe(request: Request = None):
"""Download launcher EXE file (Windows)"""
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
if not file_path.exists():
raise HTTPException(404, "EXE file not found")
if request:
return await send_file_async(file_path, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
return FileResponse(
path=file_path,
filename="ZernMCLauncher.exe",
@@ -1161,7 +1411,7 @@ async def download_launcher_exe():
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str):
async def download_launcher_zip(filename: str, request: Request = None):
"""Download specific launcher ZIP archive"""
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) or not filename.endswith(".zip"):
@@ -1172,6 +1422,9 @@ async def download_launcher_zip(filename: str):
if not file_path.exists():
raise HTTPException(404, "ZIP file not found")
if request:
return await send_file_async(file_path, request, content_type="application/zip", cache=True)
return FileResponse(
path=file_path,
filename=filename,
@@ -1239,6 +1492,76 @@ async def get_launcher_meta_list():
}
@app.get("/launcher/mirrors")
async def get_launcher_mirrors():
"""Get list of available mirrors"""
mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
return {"mirrors": mirrors_list}
# ====================== SYNC FOR MIRRORS ======================
def verify_sync_api_key(request: Request):
"""Verify API key for sync endpoints"""
api_key = request.headers.get("X-Sync-Key")
if not api_key or api_key != SYNC_API_KEY:
raise HTTPException(401, "Invalid or missing sync API key")
@app.get("/launcher/sync/{version}")
async def get_sync_manifest(version: str, request: Request):
"""Get sync manifest for mirror servers - returns files to download/delete"""
verify_sync_api_key(request)
if SERVER_ROLE != "main":
raise HTTPException(403, "Sync only available on main server")
# Check for incremental sync (if-modified-since)
last_sync = request.headers.get("If-Modified-Since")
# Get server meta
meta = get_launcher_version_meta(version)
if not meta:
raise HTTPException(404, f"Version {version} not found")
# Build sync response
sync_data = {
"version": version,
"files": [],
"delete": [], # Files that were removed from main
"timestamp": datetime.utcnow().isoformat()
}
for f in meta.get("files", []):
sync_data["files"].append({
"path": f["path"],
"size": f["size"],
"hash": f["hash"],
"url": f"/launcher/file/{version}/{f['path']}"
})
return sync_data
@app.get("/launcher/sync/{version}/file/{file_path:path}")
async def sync_download_file(version: str, file_path: str, request: Request):
"""Download file for mirror sync"""
verify_sync_api_key(request)
if SERVER_ROLE != "main":
raise HTTPException(403, "Sync only available on main server")
full_path = BUILDS_DIR / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, "File not found")
return await send_file_async(full_path, request, cache=False)
@app.get("/launcher/meta/{version}")
async def get_launcher_version_meta_handler(version: str):
"""Get meta for specific launcher version"""
@@ -1306,17 +1629,21 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
else:
raise HTTPException(404, "File not found: " + file_path)
return FileResponse(full_path, direct_passthrough=True)
return await send_file_async(full_path, request, cache=True)
@app.get("/launcher/download/zip/{version}")
async def download_launcher_zip_version(version: str):
async def download_launcher_zip_version(version: str, request: Request = None):
"""Download full ZIP for specific version (for new installs)"""
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
if not zip_path.exists():
raise HTTPException(404, f"ZIP for version {version} not found")
if request:
return await send_file_async(zip_path, request, content_type="application/zip", cache=True)
# Fallback without request
return FileResponse(
path=zip_path,
filename=f"ZernMC-win-{version}.zip",
@@ -1780,6 +2107,9 @@ if __name__ == "__main__":
if args.test:
import asyncio
asyncio.run(run_test_mode())
elif args.sync:
import asyncio
asyncio.run(run_sync_mode())
elif args.dev:
run_development_mode(args.host, args.port, args.reload)
else:
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Lightweight Mirror Server - only serves static files
"""
import os
import asyncio
from pathlib import Path
import structlog
import httpx
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "http://87.120.187.36:1582")
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta")
PORT = int(os.environ.get("PORT", "1582"))
BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
PACKS_DIR = Path("packs")
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
logging = structlog.get_logger()
async def sync_with_main():
"""Sync files from main server"""
logging.info(f"Syncing from {MAIN_SERVER_URL}")
client = httpx.AsyncClient(timeout=120.0)
headers = {"X-Master-Key": MASTER_KEY}
try:
# Get launcher info
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/info", headers=headers)
if resp.status_code != 200:
logging.error(f"Failed to get launcher info: {resp.status_code}")
return
data = resp.json()
current_version = data.get("current_version", "1.0.9")
files = data.get("files", {})
zips = files.get("zips", [])
logging.info(f"Current version: {current_version}, zips: {len(zips)}")
# Download latest ZIP
for z in zips:
if not z.get("is_legacy"):
zip_filename = z.get("filename")
zip_path = BUILDS_DIR / zip_filename
if not zip_path.exists():
logging.info(f"Downloading {zip_filename}...")
# Try direct download
download_url = f"{MAIN_SERVER_URL}/launcher/download/zip/{zip_filename}"
resp = await client.get(download_url, headers=headers)
if resp.status_code == 200:
zip_path.write_bytes(resp.content)
logging.info(f"Downloaded {zip_filename}")
# Extract
version = z.get("version")
extract_to = VERSIONS_DIR / version
extract_to.mkdir(parents=True, exist_ok=True)
import zipfile
with zipfile.ZipFile(zip_path, 'r') as zf:
zf.extractall(extract_to)
logging.info(f"Extracted {version}")
# Get launcher meta
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/meta/{current_version}", headers=headers)
if resp.status_code == 200:
(BUILDS_DIR / "meta.json").write_text(resp.text)
logging.info("Meta synced")
# Sync packs list
resp = await client.get(f"{MAIN_SERVER_URL}/packs", headers=headers)
if resp.status_code == 200:
packs_data = resp.json()
packs = packs_data.get("packs", [])
logging.info(f"Found {len(packs)} packs")
for pack in packs:
pack_name = pack.get("name")
pack_meta_url = f"{MAIN_SERVER_URL}/pack/meta/{pack_name}"
resp = await client.get(pack_meta_url, headers=headers)
if resp.status_code == 200:
pack_dir = PACKS_DIR / pack_name
pack_dir.mkdir(parents=True, exist_ok=True)
(pack_dir / "meta.json").write_text(resp.text)
logging.info(f"Synced pack: {pack_name}")
finally:
await client.aclose()
logging.info("Sync complete")
async def run_server():
"""Run static server"""
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import StreamingResponse
import aiofiles
import mimetypes
import re
import uvicorn
app = FastAPI(title="ZernMC Mirror")
async def send_file(file_path: Path, request: Request):
if not file_path.exists():
raise HTTPException(404, "Not found")
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
file_size = file_path.stat().st_size
range_header = request.headers.get("range")
if range_header:
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
if match:
start = int(match.group(1))
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
content_length = end - start + 1
async with aiofiles.open(file_path, "rb") as f:
await f.seek(start)
chunk = await f.read(content_length)
return StreamingResponse(iter([chunk]), status_code=206, media_type=content_type,
headers={"Content-Range": f"bytes {start}-{end}/{file_size}", "Accept-Ranges": "bytes", "Content-Length": str(content_length)})
async def file_iter():
async with aiofiles.open(file_path, "rb") as f:
while True:
chunk = await f.read(65536)
if not chunk:
break
yield chunk
return StreamingResponse(file_iter(), media_type=content_type,
headers={"Accept-Ranges": "bytes", "Content-Length": str(file_size)})
@app.get("/launcher/info")
async def get_launcher_info():
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
return {"current_version": "unknown", "files": {}}
@app.get("/launcher/version")
async def get_version():
return await get_launcher_info()
@app.get("/launcher/file/{version}/{file_path:path}")
async def get_launcher_file(version: str, file_path: str, request: Request):
full_path = BUILDS_DIR / "versions" / version / file_path
if ".." in file_path:
raise HTTPException(403, "Invalid path")
if not full_path.exists():
raise HTTPException(404, f"File not found: {file_path}")
return await send_file(full_path, request)
@app.get("/launcher/download/zip/{filename}")
async def download_zip(filename: str, request: Request):
return await send_file(BUILDS_DIR / filename, request)
@app.get("/launcher/meta/{version}")
async def get_meta(version: str):
meta_path = BUILDS_DIR / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Meta not found")
@app.get("/launcher/mirrors")
async def get_mirrors():
return {"mirrors": [{"name": "main", "url": MAIN_SERVER_URL}]}
@app.get("/packs")
async def list_packs():
import json
packs = []
for pack_dir in PACKS_DIR.iterdir():
if pack_dir.is_dir():
meta_path = pack_dir / "meta.json"
if meta_path.exists():
try:
meta = json.loads(meta_path.read_text())
packs.append({
"name": pack_dir.name,
"version": meta.get("version", 1),
"files_count": len(meta.get("files", {}))
})
except:
packs.append({"name": pack_dir.name, "error": "invalid"})
return {"packs": packs}
@app.get("/pack/{pack_name}")
async def get_pack(pack_name: str):
meta_path = PACKS_DIR / pack_name / "meta.json"
if meta_path.exists():
import json
return json.loads(meta_path.read_text())
raise HTTPException(404, "Pack not found")
@app.get("/pack/meta/{pack_name}")
async def get_pack_meta(pack_name: str):
return await get_pack(pack_name)
@app.get("/pack/{pack_name}/diff")
async def get_pack_diff(pack_name: str):
# For mirror, just return empty diff (no local changes)
return {"added": [], "removed": [], "changed": []}
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request):
return await send_file(PACKS_DIR / pack_name / file_path, request)
config = uvicorn.Config(app, host="0.0.0.0", port=PORT, log_level="info")
server = uvicorn.Server(config)
await server.serve()
async def main():
logging.info("Starting ZernMC Mirror Server")
await sync_with_main()
await run_server()
if __name__ == "__main__":
asyncio.run(main())