fix: критичные баги — отсутствие auth на pack файлах, сломанный proxy fallback, race condition installInProgress, JSON парсинг, падение loadMetadata
This commit is contained in:
@@ -130,7 +130,9 @@ public class Instance {
|
|||||||
this.isServerPack = meta.isServerPack;
|
this.isServerPack = meta.isServerPack;
|
||||||
this.serverVersion = meta.serverVersion;
|
this.serverVersion = meta.serverVersion;
|
||||||
this.serverPackName = meta.serverPackName;
|
this.serverPackName = meta.serverPackName;
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) {
|
||||||
|
System.err.println("[Instance] Failed to load metadata for " + name + ": " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveMetadata() {
|
private void saveMetadata() {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.Headers;
|
import com.sun.net.httpserver.Headers;
|
||||||
@@ -51,7 +52,7 @@ import com.sun.net.httpserver.Headers;
|
|||||||
public class JFXLauncher extends Application {
|
public class JFXLauncher extends Application {
|
||||||
private static int PORT = 8080;
|
private static int PORT = 8080;
|
||||||
private static final String APP_TITLE = "ZernMC Launcher";
|
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 static final String LAUNCHER_SERVER = System.getProperty("launcher.server", "https://api.zernmc.ru");
|
||||||
private final LauncherAPI api = new LauncherAPI();
|
private final LauncherAPI api = new LauncherAPI();
|
||||||
private final Gson gson = new Gson();
|
private final Gson gson = new Gson();
|
||||||
private HttpServer server;
|
private HttpServer server;
|
||||||
@@ -64,7 +65,7 @@ public class JFXLauncher extends Application {
|
|||||||
private static volatile String installProgressLabel = "";
|
private static volatile String installProgressLabel = "";
|
||||||
private static volatile int installProgressCurrent = 0;
|
private static volatile int installProgressCurrent = 0;
|
||||||
private static volatile int installProgressTotal = 100;
|
private static volatile int installProgressTotal = 100;
|
||||||
private static volatile boolean installInProgress = false;
|
private static final AtomicBoolean installInProgress = new AtomicBoolean(false);
|
||||||
private static volatile String installStageName = "";
|
private static volatile String installStageName = "";
|
||||||
private static volatile int installStageIndex = 0;
|
private static volatile int installStageIndex = 0;
|
||||||
private static volatile int installStageCount = 1;
|
private static volatile int installStageCount = 1;
|
||||||
@@ -114,11 +115,11 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void setInstallInProgress(boolean inProgress) {
|
public static void setInstallInProgress(boolean inProgress) {
|
||||||
installInProgress = inProgress;
|
installInProgress.set(inProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isInstallInProgress() {
|
public static boolean isInstallInProgress() {
|
||||||
return installInProgress;
|
return installInProgress.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void appendLauncherLog(String msg) {
|
public static void appendLauncherLog(String msg) {
|
||||||
@@ -243,14 +244,9 @@ public class JFXLauncher extends Application {
|
|||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
String line;
|
String line;
|
||||||
while ((line = br.readLine()) != null) sb.append(line);
|
while ((line = br.readLine()) != null) sb.append(line);
|
||||||
String response = sb.toString();
|
org.json.JSONObject obj = new org.json.JSONObject(sb.toString());
|
||||||
int versionStart = response.indexOf("\"version\":\"");
|
if (obj.has("version")) {
|
||||||
if (versionStart >= 0) {
|
return obj.getString("version");
|
||||||
int afterVersion = versionStart + 11;
|
|
||||||
int versionEnd = response.indexOf("\"", afterVersion);
|
|
||||||
if (versionEnd > afterVersion) {
|
|
||||||
return response.substring(afterVersion, versionEnd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -669,13 +665,14 @@ public class JFXLauncher extends Application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleInstall(HttpExchange exchange) {
|
private void handleInstall(HttpExchange exchange) {
|
||||||
if (installInProgress) {
|
if (!installInProgress.compareAndSet(false, true)) {
|
||||||
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
|
sendJson(exchange, Map.of("success", false, "error", "Installation already in progress"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!api.isLoggedIn()) {
|
if (!api.isLoggedIn()) {
|
||||||
|
installInProgress.set(false);
|
||||||
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
|
sendJson(exchange, Map.of("success", false, "error", "Not authenticated"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -690,6 +687,7 @@ public class JFXLauncher extends Application {
|
|||||||
|
|
||||||
var createResult = api.instances().createInstance(name);
|
var createResult = api.instances().createInstance(name);
|
||||||
if (!createResult.isSuccess()) {
|
if (!createResult.isSuccess()) {
|
||||||
|
installInProgress.set(false);
|
||||||
sendJson(exchange, Map.of("success", false, "error", createResult.getError()));
|
sendJson(exchange, Map.of("success", false, "error", createResult.getError()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -706,7 +704,6 @@ public class JFXLauncher extends Application {
|
|||||||
|
|
||||||
sendJson(exchange, Map.of("success", true, "message", "Installation started"));
|
sendJson(exchange, Map.of("success", true, "message", "Installation started"));
|
||||||
|
|
||||||
setInstallInProgress(true);
|
|
||||||
setInstallProgressWithStage("Preparing...", 0, 100, "Preparing", 0, 4);
|
setInstallProgressWithStage("Preparing...", 0, 100, "Preparing", 0, 4);
|
||||||
|
|
||||||
Thread installThread = new Thread(() -> {
|
Thread installThread = new Thread(() -> {
|
||||||
@@ -777,15 +774,15 @@ public class JFXLauncher extends Application {
|
|||||||
progress.put("label", installProgressLabel);
|
progress.put("label", installProgressLabel);
|
||||||
progress.put("current", installProgressCurrent);
|
progress.put("current", installProgressCurrent);
|
||||||
progress.put("total", installProgressTotal);
|
progress.put("total", installProgressTotal);
|
||||||
progress.put("inProgress", installInProgress);
|
progress.put("inProgress", installInProgress.get());
|
||||||
progress.put("stageName", installStageName);
|
progress.put("stageName", installStageName);
|
||||||
progress.put("stageIndex", installStageIndex);
|
progress.put("stageIndex", installStageIndex);
|
||||||
progress.put("stageCount", Math.max(installStageCount, 1));
|
progress.put("stageCount", Math.max(installStageCount, 1));
|
||||||
|
|
||||||
if (installInProgress && installProgressTotal > 0) {
|
if (installInProgress.get() && installProgressTotal > 0) {
|
||||||
progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal));
|
progress.put("percent", (int) ((installProgressCurrent * 100.0) / installProgressTotal));
|
||||||
} else {
|
} else {
|
||||||
progress.put("percent", installInProgress ? 0 : 100);
|
progress.put("percent", installInProgress.get() ? 0 : 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendJson(exchange, Map.of("success", true, "data", progress));
|
sendJson(exchange, Map.of("success", true, "data", progress));
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class Config {
|
|||||||
private static final Properties props = new Properties();
|
private static final Properties props = new Properties();
|
||||||
|
|
||||||
private static volatile int maxMemory = 4096;
|
private static volatile int maxMemory = 4096;
|
||||||
private static volatile String serverUrl = "http://87.120.187.36:1582";
|
private static volatile String serverUrl = "https://api.zernmc.ru";
|
||||||
private static volatile String lastUsername = "Player";
|
private static volatile String lastUsername = "Player";
|
||||||
private static volatile int windowWidth = 1280;
|
private static volatile int windowWidth = 1280;
|
||||||
private static volatile int windowHeight = 720;
|
private static volatile int windowHeight = 720;
|
||||||
@@ -238,7 +238,14 @@ public class Config {
|
|||||||
public static long getSystemTotalRamMB() {
|
public static long getSystemTotalRamMB() {
|
||||||
long totalMb = getTotalSystemRamMB();
|
long totalMb = getTotalSystemRamMB();
|
||||||
if (totalMb > 0) return totalMb;
|
if (totalMb > 0) return totalMb;
|
||||||
return Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
try {
|
||||||
|
java.lang.management.OperatingSystemMXBean osBean = java.lang.management.ManagementFactory.getOperatingSystemMXBean();
|
||||||
|
if (osBean instanceof com.sun.management.OperatingSystemMXBean sunBean) {
|
||||||
|
return sunBean.getTotalMemorySize() / (1024 * 1024);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
long jvmMax = Runtime.getRuntime().maxMemory() / (1024 * 1024);
|
||||||
|
return Math.max(jvmMax, 4096);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String getSystemJvmFlags() {
|
public static String getSystemJvmFlags() {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class ZHttpClient {
|
|||||||
.version(HttpClient.Version.HTTP_1_1)
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private static String BASE_URL = "http://87.120.187.36:1582";
|
private static String BASE_URL = "https://api.zernmc.ru";
|
||||||
|
|
||||||
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);
|
||||||
@@ -41,7 +41,7 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum ServiceType {
|
public enum ServiceType {
|
||||||
ZERN_SERVER("http://87.120.187.36:1582", true),
|
ZERN_SERVER("https://api.zernmc.ru", 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),
|
||||||
@@ -306,7 +306,7 @@ public class ZHttpClient {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
String encodedUrl = URLEncoder.encode(url, StandardCharsets.UTF_8);
|
||||||
String proxyUrl = BASE_URL + "/download?url=" + encodedUrl;
|
String proxyUrl = BASE_URL + "/proxy/download?url=" + encodedUrl;
|
||||||
|
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(proxyUrl))
|
.uri(URI.create(proxyUrl))
|
||||||
@@ -373,10 +373,6 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String get(String endpoint) throws IOException, InterruptedException {
|
public static String get(String endpoint) throws IOException, InterruptedException {
|
||||||
if (useProxyMode.get()) {
|
|
||||||
return proxyGet(endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(BASE_URL + endpoint))
|
.uri(URI.create(BASE_URL + endpoint))
|
||||||
@@ -402,33 +398,6 @@ public class ZHttpClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String proxyGet(String endpoint) throws IOException {
|
|
||||||
try {
|
|
||||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
|
||||||
.uri(URI.create(BASE_URL + "/proxy" + endpoint))
|
|
||||||
.timeout(Duration.ofSeconds(30))
|
|
||||||
.header("User-Agent", "ZernMC-Launcher/1.0")
|
|
||||||
.GET();
|
|
||||||
|
|
||||||
String accessToken = AuthManager.getAccessToken();
|
|
||||||
if (accessToken != null && !accessToken.equals("0")) {
|
|
||||||
requestBuilder.header("Authorization", "Bearer " + accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpRequest request = requestBuilder.build();
|
|
||||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
|
||||||
|
|
||||||
if (response.statusCode() != 200) {
|
|
||||||
throw new IOException("HTTP " + response.statusCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
proxySuccessCount++;
|
|
||||||
return response.body();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IOException("Proxy error: " + e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
|
public static List<String> getFabricLoaderVersions() throws IOException, InterruptedException {
|
||||||
String url = "https://meta.fabricmc.net/v2/versions/loader";
|
String url = "https://meta.fabricmc.net/v2/versions/loader";
|
||||||
return parseFabricVersionsFromJson(getWithSmartProxy(url));
|
return parseFabricVersionsFromJson(getWithSmartProxy(url));
|
||||||
|
|||||||
+254
-93
@@ -46,8 +46,8 @@ VERSIONS_DIR = BUILDS_DIR / "versions"
|
|||||||
|
|
||||||
# Mirror configuration
|
# Mirror configuration
|
||||||
LAUNCHER_MIRRORS = {
|
LAUNCHER_MIRRORS = {
|
||||||
"main": "http://87.120.187.36:1582",
|
"main": "https://api.zernmc.ru",
|
||||||
"mirror-1": "http://212.22.82.243:1582",
|
"mirror-1": "https://api.zernmc.online",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Server role: "main" or "mirror"
|
# Server role: "main" or "mirror"
|
||||||
@@ -57,12 +57,11 @@ SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror
|
|||||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
|
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
|
||||||
|
|
||||||
# IP Filtering Configuration
|
# IP Filtering Configuration
|
||||||
import os
|
|
||||||
import middleware as mw
|
import middleware as mw
|
||||||
|
|
||||||
# Only configure manually blocked IPs at import time
|
# Only configure manually blocked IPs at import time
|
||||||
# Public blocklists are loaded in lifespan (once, not per-worker)
|
# Public blocklists are loaded in lifespan (once, not per-worker)
|
||||||
MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""}
|
MANUAL_BLOCKED_IPS = set(os.environ.get("BLOCKED_IPS", "").split(",")) - {""} # noqa: F811
|
||||||
|
|
||||||
# Cache file for blocklist (load once)
|
# Cache file for blocklist (load once)
|
||||||
BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
BLOCKLIST_CACHE_FILE = Path("data/blocklist_cache.txt")
|
||||||
@@ -169,49 +168,17 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
logger.info("All packs ready. Server is running.")
|
logger.info("All packs ready. Server is running.")
|
||||||
|
|
||||||
# Mirror sync with main server
|
# Mirror sync with main server (full diff: hash check + delete stale)
|
||||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||||
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
|
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
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}
|
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
|
result = await sync_mirror_full(client, headers, MAIN_SERVER_URL)
|
||||||
if resp.status_code != 200:
|
if result:
|
||||||
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
|
await sync_mirror_self_update(client, headers, MAIN_SERVER_URL)
|
||||||
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:
|
except Exception as e:
|
||||||
logger.warning(f"Mirror sync failed: {e}")
|
logger.warning(f"Initial mirror sync failed: {e}")
|
||||||
|
|
||||||
# Scan launcher versions and generate meta
|
# Scan launcher versions and generate meta
|
||||||
logger.info("Scanning launcher versions...")
|
logger.info("Scanning launcher versions...")
|
||||||
@@ -237,49 +204,18 @@ async def lifespan(app: FastAPI):
|
|||||||
global proxy_client
|
global proxy_client
|
||||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||||
|
|
||||||
# Start background sync task for mirrors
|
# Start background sync + self-update task for mirrors
|
||||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def periodic_sync():
|
async def periodic_sync():
|
||||||
sync_interval = 7200 # 2 hours
|
sync_interval = 1800 # 30 minutes
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(sync_interval)
|
await asyncio.sleep(sync_interval)
|
||||||
try:
|
try:
|
||||||
logger.info("Periodic mirror sync started...")
|
logger.info("Periodic mirror sync started...")
|
||||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||||
|
result = await sync_mirror_full(proxy_client, headers, MAIN_SERVER_URL)
|
||||||
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
if result:
|
||||||
main_data = resp.json()
|
await sync_mirror_self_update(proxy_client, headers, MAIN_SERVER_URL)
|
||||||
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:
|
except Exception as e:
|
||||||
logger.warning(f"Periodic sync error: {e}")
|
logger.warning(f"Periodic sync error: {e}")
|
||||||
|
|
||||||
@@ -294,6 +230,236 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("Server shutting down...")
|
logger.info("Server shutting down...")
|
||||||
|
|
||||||
|
|
||||||
|
# ====================== MIRROR SYNC HELPERS ======================
|
||||||
|
|
||||||
|
async def _calculate_file_hash_async(file_path: Path) -> Optional[str]:
|
||||||
|
"""Calculate SHA256 hash of a file asynchronously"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
def _hash():
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
while chunk := f.read(65536):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
return await loop.run_in_executor(None, _hash)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_mirror_full(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
headers: dict,
|
||||||
|
main_url: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Full mirror sync: compare by hash, download new/changed, delete stale.
|
||||||
|
Returns True if sync completed without critical errors."""
|
||||||
|
try:
|
||||||
|
# Get latest version from main
|
||||||
|
resp = await client.get(f"{main_url}/launcher/version", timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
main_data = resp.json()
|
||||||
|
main_version = main_data.get("version")
|
||||||
|
if not main_version:
|
||||||
|
logger.warning("sync_mirror_full: no version from main")
|
||||||
|
return False
|
||||||
|
logger.info(f"sync_mirror_full: main version = {main_version}")
|
||||||
|
|
||||||
|
# Get sync manifest (full file list with hashes)
|
||||||
|
resp = await client.get(
|
||||||
|
f"{main_url}/launcher/sync/{main_version}", headers=headers, timeout=30
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(f"sync_mirror_full: manifest returned {resp.status_code}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
sync_data = resp.json()
|
||||||
|
server_files = sync_data.get("files", [])
|
||||||
|
logger.info(f"sync_mirror_full: {len(server_files)} files in manifest")
|
||||||
|
|
||||||
|
# Build server file map
|
||||||
|
server_map = {}
|
||||||
|
for f in server_files:
|
||||||
|
server_map[f["path"]] = f
|
||||||
|
|
||||||
|
# Scan local builds directory
|
||||||
|
local_files = {}
|
||||||
|
if BUILDS_DIR.exists():
|
||||||
|
for fpath in BUILDS_DIR.rglob("*"):
|
||||||
|
if fpath.is_file() and fpath.name not in ("meta.json", "blocklist_cache.txt"):
|
||||||
|
rel = str(fpath.relative_to(BUILDS_DIR))
|
||||||
|
local_files[rel] = ""
|
||||||
|
|
||||||
|
# Download new/changed files
|
||||||
|
downloaded = 0
|
||||||
|
for path, info in server_map.items():
|
||||||
|
local_path = BUILDS_DIR / path
|
||||||
|
need_download = False
|
||||||
|
|
||||||
|
if not local_path.exists():
|
||||||
|
need_download = True
|
||||||
|
logger.info(f" New: {path}")
|
||||||
|
else:
|
||||||
|
local_hash = await _calculate_file_hash_async(local_path)
|
||||||
|
server_hash = info.get("hash", "").replace("sha256:", "")
|
||||||
|
if local_hash and local_hash != server_hash:
|
||||||
|
need_download = True
|
||||||
|
logger.info(f" Changed: {path}")
|
||||||
|
|
||||||
|
if need_download:
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_url = f"{main_url}/launcher/sync/{main_version}/file/{path}"
|
||||||
|
try:
|
||||||
|
resp = await client.get(file_url, headers=headers, timeout=120)
|
||||||
|
resp.raise_for_status()
|
||||||
|
local_path.write_bytes(resp.content)
|
||||||
|
downloaded += 1
|
||||||
|
logger.debug(f" Downloaded: {path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" Failed to download {path}: {e}")
|
||||||
|
|
||||||
|
# Delete files that exist locally but not on server
|
||||||
|
deleted = 0
|
||||||
|
for local_path in list(local_files.keys()):
|
||||||
|
if local_path not in server_map:
|
||||||
|
del_path = BUILDS_DIR / local_path
|
||||||
|
try:
|
||||||
|
del_path.unlink()
|
||||||
|
deleted += 1
|
||||||
|
logger.info(f" Deleted stale: {local_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" Failed to delete {local_path}: {e}")
|
||||||
|
|
||||||
|
# Clean empty directories
|
||||||
|
for fpath in sorted(BUILDS_DIR.rglob("*"), key=lambda p: str(p), reverse=True):
|
||||||
|
if fpath.is_dir() and fpath != BUILDS_DIR:
|
||||||
|
try:
|
||||||
|
if not any(fpath.iterdir()):
|
||||||
|
fpath.rmdir()
|
||||||
|
logger.debug(f" Removed empty dir: {fpath.relative_to(BUILDS_DIR)}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"sync_mirror_full: downloaded={downloaded}, deleted={deleted}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.warning(f"sync_mirror_full HTTP error: {e.response.status_code} - {e.response.text[:200]}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"sync_mirror_full error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_mirror_self_update(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
headers: dict,
|
||||||
|
main_url: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if the mirror's own launcher distribution needs updating.
|
||||||
|
Downloads new ZIP + extracts it, or replaces the bootstrap JAR."""
|
||||||
|
try:
|
||||||
|
# Get current launcher version info
|
||||||
|
resp = await client.get(f"{main_url}/launcher/version", timeout=15)
|
||||||
|
resp.raise_for_status()
|
||||||
|
version_info = resp.json()
|
||||||
|
server_version = version_info.get("version", "")
|
||||||
|
if not server_version:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check current local version
|
||||||
|
local_version = "0.0.0"
|
||||||
|
build_version_file = BUILDS_DIR / "build.version"
|
||||||
|
if build_version_file.exists():
|
||||||
|
local_version = build_version_file.read_text().strip()
|
||||||
|
|
||||||
|
if local_version == server_version:
|
||||||
|
logger.debug("sync_mirror_self_update: already up to date")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"sync_mirror_self_update: local={local_version}, server={server_version}")
|
||||||
|
|
||||||
|
# Parse versions
|
||||||
|
def parse_ver(v: str) -> tuple:
|
||||||
|
try:
|
||||||
|
return tuple(int(x) for x in v.split("."))
|
||||||
|
except Exception:
|
||||||
|
return (0, 0, 0)
|
||||||
|
|
||||||
|
if not (parse_ver(server_version) > parse_ver(local_version)):
|
||||||
|
logger.debug("sync_mirror_self_update: server version not newer")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get latest ZIP info from the version endpoint
|
||||||
|
zip_filename = None
|
||||||
|
zip_info = version_info.get("all_zips", [])
|
||||||
|
if zip_info:
|
||||||
|
zip_filename = zip_info[0].get("filename")
|
||||||
|
|
||||||
|
if not zip_filename:
|
||||||
|
logger.warning("sync_mirror_self_update: no ZIP found in version info")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Download the new launcher ZIP to a temp file
|
||||||
|
logger.info(f"sync_mirror_self_update: downloading {zip_filename}")
|
||||||
|
zip_url = f"{main_url}/launcher/download/zip/{zip_filename}"
|
||||||
|
zip_path = BUILDS_DIR / f".sync_{zip_filename}"
|
||||||
|
|
||||||
|
resp = await client.get(zip_url, timeout=300)
|
||||||
|
resp.raise_for_status()
|
||||||
|
zip_path.write_bytes(resp.content)
|
||||||
|
logger.info(f"sync_mirror_self_update: downloaded {len(resp.content)} bytes")
|
||||||
|
|
||||||
|
# Extract to a temp directory
|
||||||
|
import zipfile
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
extract_dir = Path(tmpdir) / "extracted"
|
||||||
|
extract_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(zip_path) as zf:
|
||||||
|
zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
# Move extracted files to builds directory, replacing old ones
|
||||||
|
moved = 0
|
||||||
|
for item in extract_dir.rglob("*"):
|
||||||
|
if item.is_file() and item.name not in ("meta.json", "build.version"):
|
||||||
|
rel = item.relative_to(extract_dir)
|
||||||
|
target = BUILDS_DIR / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
shutil.copy2(item, target)
|
||||||
|
moved += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f" Failed to copy {rel}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"sync_mirror_self_update: moved {moved} files")
|
||||||
|
|
||||||
|
# Clean up temp ZIP
|
||||||
|
zip_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Update version file
|
||||||
|
build_version_file.write_text(server_version)
|
||||||
|
logger.info(f"sync_mirror_self_update: updated to v{server_version}")
|
||||||
|
|
||||||
|
# Regenerate meta for the new version
|
||||||
|
generate_launcher_builds_meta()
|
||||||
|
extract_new_format_versions()
|
||||||
|
|
||||||
|
# Write a restart flag so the process manager can restart the server
|
||||||
|
restart_flag = BUILDS_DIR / ".restart-required"
|
||||||
|
restart_flag.write_text(server_version)
|
||||||
|
logger.warning(f"sync_mirror_self_update: restart flag written ({restart_flag})")
|
||||||
|
logger.warning("sync_mirror_self_update: server restart recommended to pick up new launcher files")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"sync_mirror_self_update error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ======================
|
# ====================== ШАБЛОН СТРАНИЦЫ АКТИВАЦИИ ======================
|
||||||
ACTIVATE_PASS_HTML = """
|
ACTIVATE_PASS_HTML = """
|
||||||
@@ -647,10 +813,12 @@ class CacheControlMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
return
|
||||||
|
|
||||||
async def send_wrapper(status, headers, *args, **kwargs):
|
async def send_wrapper(message):
|
||||||
cache_headers = [(b"cache-control", b"public, max-age=86400")]
|
if message.get("type") == "http.response.start":
|
||||||
headers = list(headers) + cache_headers
|
headers = message.get("headers", [])
|
||||||
await send(status, headers, *args, **kwargs)
|
cache_headers = [(b"cache-control", b"public, max-age=86400")]
|
||||||
|
message["headers"] = list(headers) + cache_headers
|
||||||
|
await send(message)
|
||||||
|
|
||||||
await self.app(scope, receive, send_wrapper)
|
await self.app(scope, receive, send_wrapper)
|
||||||
|
|
||||||
@@ -840,7 +1008,7 @@ async def list_packs(
|
|||||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
meta = json.load(f)
|
meta = json.load(f)
|
||||||
updated_at = meta.get("updated_at")
|
updated_at = meta.get("updated_at")
|
||||||
if updated_at and isinstance(updated_at, datetime):
|
if isinstance(updated_at, datetime):
|
||||||
updated_at = updated_at.isoformat()
|
updated_at = updated_at.isoformat()
|
||||||
|
|
||||||
desc_path = pack_dir / "description.txt"
|
desc_path = pack_dir / "description.txt"
|
||||||
@@ -997,8 +1165,10 @@ async def get_pack_manifest(pack_name: str, request: Request, current_user: dict
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
@app.get("/pack/{pack_name}/file/{file_path:path}")
|
||||||
async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
async def get_pack_file(pack_name: str, file_path: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||||
"""Get a file from a pack"""
|
"""Get a file from a pack"""
|
||||||
|
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
|
||||||
|
raise HTTPException(403, "Requires active pass")
|
||||||
full_path = PACKS_DIR / pack_name / file_path
|
full_path = PACKS_DIR / pack_name / file_path
|
||||||
client_ip = request.client.host if request.client else None
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
@@ -1044,8 +1214,6 @@ def get_current_launcher_version() -> str:
|
|||||||
|
|
||||||
def parse_version(version_str: str) -> dict:
|
def parse_version(version_str: str) -> dict:
|
||||||
"""Parse version string to determine if it's new or legacy format"""
|
"""Parse version string to determine if it's new or legacy format"""
|
||||||
import re
|
|
||||||
|
|
||||||
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)$', version_str)
|
match = re.match(r'^(\d+)\.(\d+)\.(\d+)(.*)$', version_str)
|
||||||
if not match:
|
if not match:
|
||||||
return {"major": 0, "minor": 0, "patch": 0, "suffix": version_str, "is_legacy": True}
|
return {"major": 0, "minor": 0, "patch": 0, "suffix": version_str, "is_legacy": True}
|
||||||
@@ -1073,7 +1241,6 @@ def is_new_format(filename: str) -> bool:
|
|||||||
|
|
||||||
def calculate_file_hash(file_path: Path) -> str:
|
def calculate_file_hash(file_path: Path) -> str:
|
||||||
"""Calculate SHA256 hash of a file"""
|
"""Calculate SHA256 hash of a file"""
|
||||||
import hashlib
|
|
||||||
hash_sha = hashlib.sha256()
|
hash_sha = hashlib.sha256()
|
||||||
with open(file_path, 'rb') as f:
|
with open(file_path, 'rb') as f:
|
||||||
while chunk := f.read(8192):
|
while chunk := f.read(8192):
|
||||||
@@ -1083,8 +1250,6 @@ def calculate_file_hash(file_path: Path) -> str:
|
|||||||
|
|
||||||
def generate_launcher_builds_meta():
|
def generate_launcher_builds_meta():
|
||||||
"""Generate meta.json in builds/ directory for incremental updates"""
|
"""Generate meta.json in builds/ directory for incremental updates"""
|
||||||
import hashlib
|
|
||||||
|
|
||||||
version = get_current_launcher_version()
|
version = get_current_launcher_version()
|
||||||
if not version:
|
if not version:
|
||||||
return
|
return
|
||||||
@@ -1167,7 +1332,6 @@ def scan_launcher_version(version: str) -> Optional[dict]:
|
|||||||
# Check cache first
|
# Check cache first
|
||||||
if meta_path.exists():
|
if meta_path.exists():
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1195,7 +1359,6 @@ def scan_launcher_version(version: str) -> Optional[dict]:
|
|||||||
|
|
||||||
# Save meta
|
# Save meta
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
with open(meta_path, 'w', encoding='utf-8') as f:
|
with open(meta_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(meta, f, indent=2)
|
json.dump(meta, f, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1614,7 +1777,7 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
|
|||||||
return await send_file_async(full_path, request, cache=True)
|
return await send_file_async(full_path, request, cache=True)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/launcher/download/zip/{version}")
|
@app.get("/launcher/download/zip/version/{version}")
|
||||||
async def download_launcher_zip_version(version: str, request: Request = None):
|
async def download_launcher_zip_version(version: str, request: Request = None):
|
||||||
"""Download full ZIP for specific version (for new installs)"""
|
"""Download full ZIP for specific version (for new installs)"""
|
||||||
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
|
zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip"
|
||||||
@@ -2142,10 +2305,8 @@ if __name__ == "__main__":
|
|||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
if args.test:
|
if args.test:
|
||||||
import asyncio
|
|
||||||
asyncio.run(run_test_mode())
|
asyncio.run(run_test_mode())
|
||||||
elif args.sync:
|
elif args.sync:
|
||||||
import asyncio
|
|
||||||
asyncio.run(run_sync_mode())
|
asyncio.run(run_sync_mode())
|
||||||
elif args.dev:
|
elif args.dev:
|
||||||
run_development_mode(args.host, args.port, args.reload)
|
run_development_mode(args.host, args.port, args.reload)
|
||||||
|
|||||||
Reference in New Issue
Block a user