# main.py import os from fastapi import FastAPI, HTTPException, Request from fastapi.responses import FileResponse, JSONResponse from contextlib import asynccontextmanager from pathlib import Path import json import structlog from cachetools import TTLCache import asyncio import logging from datetime import datetime import asyncio from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol # Import local modules from pack_manager import DATA_DIR, get_fabric_versions, get_forge_versions, scan_pack, get_cached_manifest, PACKS_DIR from models import PackMeta from logging_config import setup_logging from middleware import LoggingMiddleware from cli import parse_args, run_test_mode, run_production_mode, run_development_mode from log_manager import init_logging, get_logger from models import MinecraftVersion from pack_manager import get_minecraft_versions, download_minecraft_version import minecraft_launcher_lib.command as mcl_command import minecraft_launcher_lib.utils as mcl_utils from pathlib import Path logger = structlog.get_logger(__name__) # Cache for manifests - expires after 5 minutes manifest_cache = TTLCache(maxsize=100, ttl=300) @asynccontextmanager async def lifespan(app: FastAPI): args = parse_args() # Initialize logging init_logging() logger = logging.getLogger(__name__) # Determine environment if args.test: env = "test" elif args.dev: env = "development" else: env = "production" logger.info(f"Starting ZernMC Launcher Server (environment: {env})") if args.test: await run_test_mode() yield return logger.info("Scanning packs on startup...") pack_dirs = [p for p in PACKS_DIR.iterdir() if p.is_dir()] if not pack_dirs: logger.warning(f"No packs found in directory: {PACKS_DIR}") else: for pack_dir in pack_dirs: try: meta = await scan_pack(pack_dir.name) logger.info(f"Pack scanned successfully: {pack_dir.name} v{meta.version} ({len(meta.files)} files)") except Exception as e: logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True) logger.info("All packs ready. Server is running.") yield logger.info("Server shutting down...") # Create app with lifespan app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan) # Add logging middleware app.add_middleware(LoggingMiddleware) # Monkey patch to catch invalid HTTP requests original_data_received = HttpToolsProtocol.data_received def patched_data_received(self, data): try: return original_data_received(self, data) except Exception as e: client = self.transport.get_extra_info('peername') logger = logging.getLogger(__name__) logger.warning(f"Invalid HTTP request from {client}: {str(e)[:200]}") # Log raw data if possible try: raw_data = data[:500].decode('utf-8', errors='replace') logger.debug(f"Raw request data: {raw_data}") except: pass raise HttpToolsProtocol.data_received = patched_data_received @app.get("/") async def root(): """Root endpoint""" logger = logging.getLogger(__name__) logger.info("Root endpoint accessed") return { "status": "ok", "message": "ZernMC Launcher Server is running", "docs": "/docs", "redoc": "/redoc" } @app.get("/health") async def health(): """Health check endpoint""" return {"status": "healthy", "timestamp": datetime.utcnow().isoformat()} @app.get("/packs") async def list_packs(): """List all available packs""" logger = logging.getLogger(__name__) packs = [] for pack_dir in PACKS_DIR.iterdir(): if pack_dir.is_dir(): meta_path = DATA_DIR / f"{pack_dir.name}.meta" if meta_path.exists(): try: async with open(meta_path, 'r', encoding='utf-8') as f: meta = json.load(f) packs.append({ "name": pack_dir.name, "version": meta.get("version", 1), "files_count": len(meta.get("files", {})), "updated_at": meta.get("updated_at") }) except Exception as e: logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}") packs.append({ "name": pack_dir.name, "error": str(e) }) else: packs.append({ "name": pack_dir.name, "status": "not_scanned" }) return {"packs": packs} # ------------------- DIFF ENDPOINT ------------------- @app.post("/pack/{pack_name}/diff") async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: Request): """ Client sends: { "mods/jei.jar": "sha256_hash", ... } Server returns diff information """ client_ip = request.client.host if request.client else "unknown" logger.info("Received diff request", pack=pack_name, client_files_count=len(client_files), client_ip=client_ip) try: # Use cached manifest if available meta = get_cached_manifest(pack_name) if not meta: meta = await scan_pack(pack_name) except FileNotFoundError: logger.warning("Pack not found", pack=pack_name, client_ip=client_ip) raise HTTPException(404, "Pack not found") except Exception as e: logger.error("Error loading pack meta", pack=pack_name, error=str(e), exc_info=True) raise HTTPException(500, "Internal server error") to_download = [] to_delete = [] to_update = [] server_files = meta.files # Calculate what needs to be downloaded or updated for path, entry in server_files.items(): client_hash = client_files.get(path) if client_hash is None or client_hash != entry.hash: url = f"/pack/{pack_name}/file/{path}" to_download.append({ "path": path, "url": url, "size": entry.size, "hash": entry.hash }) if client_hash is not None: to_update.append(path) # Calculate what needs to be deleted for path in client_files: if path not in server_files: to_delete.append(path) logger.info("Diff calculated", pack=pack_name, version=meta.version, to_download=len(to_download), to_delete=len(to_delete), to_update=len(to_update), client_ip=client_ip) return { "version": meta.version, "to_download": to_download, "to_delete": to_delete, "to_update": to_update } @app.get("/minecraft/versions") async def list_minecraft_versions(): """List available Minecraft versions""" try: versions = await get_minecraft_versions() return {"versions": [v.model_dump() for v in versions]} except Exception as e: logger.error(f"Failed to fetch Minecraft versions: {e}") raise HTTPException(500, "Failed to fetch versions") @app.get("/minecraft/version/{version}") async def get_version_details(version: str): """Get details for specific Minecraft version""" # This would fetch version JSON from Mojang return {"version": version, "status": "available"} @app.post("/minecraft/download/{version}") async def download_version(version: str, request: Request): """Download Minecraft version""" client_ip = request.client.host if request.client else "unknown" logger.info(f"Download request for Minecraft {version}", client_ip=client_ip) version_path = Path("versions") / version version_path.mkdir(parents=True, exist_ok=True) success = await download_minecraft_version(version, version_path) if success: return {"status": "success", "version": version} raise HTTPException(404, "Version not found") @app.get("/modloaders/{loader_type}") async def get_modloaders(loader_type: str, minecraft_version: str = None): """Get available mod loaders""" if loader_type == "fabric": versions = await get_fabric_versions(minecraft_version) if minecraft_version else [] return {"loader": "fabric", "versions": versions} elif loader_type == "forge": versions = await get_forge_versions(minecraft_version) if minecraft_version else [] return {"loader": "forge", "versions": versions} elif loader_type == "vanilla": return {"loader": "vanilla", "versions": ["vanilla"]} raise HTTPException(400, "Invalid loader type") @app.get("/pack/{pack_name}") async def get_pack_manifest(pack_name: str, request: Request): """Get pack manifest with caching""" client_ip = request.client.host if request.client else "unknown" # Check cache first cached_meta = get_cached_manifest(pack_name) if cached_meta: logger.debug("Manifest served from cache", pack=pack_name, version=cached_meta.version, client_ip=client_ip) return JSONResponse( content=cached_meta.model_dump(), headers={"X-Pack-Version": str(cached_meta.version), "X-Cached": "true"} ) # Load from disk if not in cache meta_path = Path("data") / f"{pack_name}.meta" if not meta_path.exists(): logger.warning("Manifest requested but pack not found", pack=pack_name, client_ip=client_ip) raise HTTPException(404, "Pack not found") async with open(meta_path, 'r', encoding='utf-8') as f: meta_dict = json.load(f) meta = PackMeta.model_validate(meta_dict) # Update cache manifest_cache[pack_name] = meta logger.debug("Manifest served from disk", pack=pack_name, version=meta.version, client_ip=client_ip) return JSONResponse( content=meta_dict, headers={"X-Pack-Version": str(meta.version), "X-Cached": "false"} ) @app.post("/pack/{pack_name}/launch") async def get_launch_command( pack_name: str, request: Request, options: dict # клиент присылает: {"username": "...", "uuid": "...", "token": "...", "gameDir": "...", "javaPath": "...", "maxMemory": 4096} ): client_ip = request.client.host if request.client else "unknown" logger.info("Launch command requested", pack=pack_name, client_ip=client_ip) try: meta = get_cached_manifest(pack_name) or await scan_pack(pack_name) except Exception as e: raise HTTPException(404, "Pack not found") from e # Базовые опции для minecraft-launcher-lib launch_options = { "username": options.get("username", "Player"), "uuid": options.get("uuid", "00000000-0000-0000-0000-000000000000"), "token": options.get("token", "token"), "gameDirectory": options.get("gameDir", str(Path("."))), # относительный "executablePath": options.get("javaPath", "java"), # или полный путь "maxMemory": options.get("maxMemory", 4096), "customResolution": options.get("customResolution", False), # можно добавить width/height и т.д. } # minecraft_directory — это корень пака у клиента (куда скачаны файлы) minecraft_dir = launch_options["gameDirectory"] try: # Генерируем команду (библиотека сама обработает Fabric/Forge через version.json) command_list = mcl_command.get_minecraft_command( version=meta.minecraft_version, minecraft_directory=minecraft_dir, options=launch_options ) # Превращаем абсолютные пути в относительные (очень важно для портативности) relative_command = [] game_dir_path = Path(minecraft_dir).resolve() for arg in command_list: try: arg_path = Path(arg) if arg_path.is_absolute() and game_dir_path in arg_path.parents: rel = arg_path.relative_to(game_dir_path).as_posix() relative_command.append(f"./{rel}" if os.name != "nt" else rel.replace("/", "\\")) else: relative_command.append(arg) except Exception: relative_command.append(arg) # Дополнительно: если в PackMeta есть свои jvmArgs/gameArgs — мерджим if hasattr(meta, 'launch') and meta.launch: # Добавляем кастомные jvmArgs в начало и т.д. pass return { "version": meta.version, "minecraftVersion": meta.minecraft_version, "loaderType": meta.loader_type, "command": relative_command, # список строк — готов к subprocess "workingDirectory": ".", # относительный "mainClass": meta.launch.mainClass if hasattr(meta, 'launch') else None, "classpath": meta.launch.classpath if hasattr(meta, 'launch') else [] } except Exception as e: logger.error("Failed to generate launch command", pack=pack_name, error=str(e), exc_info=True) raise HTTPException(500, f"Failed to generate launch command: {str(e)}") @app.get("/pack/{pack_name}/file/{file_path:path}") async def get_pack_file(pack_name: str, file_path: str, request: Request): full_path = PACKS_DIR / pack_name / file_path client_ip = request.client.host if request.client else None if not full_path.exists() or not full_path.is_file(): logger.warning("File not found", pack=pack_name, file=file_path, client_ip=client_ip) raise HTTPException(404, "File not found") logger.info("Serving file", pack=pack_name, file=file_path, size=full_path.stat().st_size, client_ip=client_ip) return FileResponse(full_path) @app.get("/launcher/version") async def get_launcher_version(): """Возвращает информацию о текущей версии лаунчера""" version_file = Path("builds/build.version") version = "1.0.0" if version_file.exists(): version = version_file.read_text().strip() return { "version": version, "download_jar": "/launcher/download?type=jar", "download_exe": "/launcher/download?type=exe", "updated_at": datetime.utcnow().isoformat() } @app.get("/launcher/download") async def download_launcher(type: str = "exe"): """Отдаёт файл лаунчера""" if type == "exe": file_path = Path("builds/ZernMCLauncher.exe") filename = "ZernMCLauncher.exe" else: file_path = Path("builds/ZernMCLauncher.jar") filename = "ZernMCLauncher.jar" if not file_path.exists(): raise HTTPException(404, "Launcher file not found") return FileResponse( path=file_path, filename=filename, media_type="application/octet-stream" ) if __name__ == "__main__": args = parse_args() if args.test: # Test mode runs within lifespan import asyncio asyncio.run(run_test_mode()) elif args.dev: run_development_mode(args.host, args.port, args.reload) else: # Default to production run_production_mode(args.host, args.port, args.workers)