server update
This commit is contained in:
+443
@@ -0,0 +1,443 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user