Both | БЛЯЯЯ ЗАГРУЗКА ПАКОВ С СЕРВЕРА СЮДААА

This commit is contained in:
Sashegdev
2026-04-06 00:32:36 +00:00
parent 4edbe7e910
commit 0b4af1353d
13 changed files with 1482 additions and 373 deletions
+222 -96
View File
@@ -1,5 +1,3 @@
import os
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from contextlib import asynccontextmanager
@@ -7,34 +5,32 @@ from pathlib import Path
import json
import structlog
from cachetools import TTLCache
import asyncio
import logging
from datetime import datetime
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 pack_manager import DATA_DIR, 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
from log_manager import init_logging
logger = structlog.get_logger(__name__)
# Cache for manifests - expires after 5 minutes
manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds")
@asynccontextmanager
async def lifespan(app: FastAPI):
args = parse_args()
# Initialize logging
init_logging()
logger = logging.getLogger(__name__)
#logger = logging.getLogger(__name__)
# Determine environment
if args.test:
@@ -46,6 +42,11 @@ async def lifespan(app: FastAPI):
logger.info(f"Starting ZernMC Launcher Server (environment: {env})")
# Create directories if they don't exist
BUILDS_DIR.mkdir(exist_ok=True)
PACKS_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)
if args.test:
await run_test_mode()
yield
@@ -69,6 +70,7 @@ async def lifespan(app: FastAPI):
yield
logger.info("Server shutting down...")
# Create app with lifespan
app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
@@ -85,17 +87,35 @@ def patched_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
# Показываем первые 200 байт запроса в HEX для диагностики
hex_preview = data[:100].hex() if len(data) > 0 else "empty"
logger.error(f"Invalid HTTP request from {client}")
logger.error(f"Error: {str(e)}")
logger.error(f"First 100 bytes (hex): {hex_preview}")
try:
raw_data = data[:500].decode('utf-8', errors='replace')
logger.debug(f"Raw request data: {raw_data}")
logger.error(f"Raw request data: {repr(raw_data)}")
except:
pass
raise
# Не перевыбрасываем исключение, а возвращаем 400 ответ
# Это важно! Иначе клиент не получит ответ
try:
response = b"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: 21\r\n\r\nInvalid HTTP request"
self.transport.write(response)
self.transport.close()
except:
pass
return
HttpToolsProtocol.data_received = patched_data_received
# ====================== ОСНОВНЫЕ ЭНДПОИНТЫ ======================
@app.get("/")
async def root():
"""Root endpoint"""
@@ -108,12 +128,15 @@ async def root():
"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"""
@@ -125,13 +148,21 @@ async def list_packs():
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:
with open(meta_path, 'r', encoding='utf-8') as f:
meta = json.load(f)
# Исправлено: конвертируем updated_at в строку если это datetime
updated_at = meta.get("updated_at")
if updated_at and isinstance(updated_at, datetime):
updated_at = updated_at.isoformat()
packs.append({
"name": pack_dir.name,
"version": meta.get("version", 1),
"files_count": len(meta.get("files", {})),
"updated_at": meta.get("updated_at")
"updated_at": updated_at,
"minecraft_version": meta.get("minecraft_version", "unknown"),
"loader_type": meta.get("loader_type", "vanilla"),
"loader_version": meta.get("loader_version")
})
except Exception as e:
logger.error(f"Failed to load pack meta for {pack_dir.name}: {e}")
@@ -147,22 +178,28 @@ async def list_packs():
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):
async def get_pack_diff(pack_name: str, request: Request):
"""
Client sends: { "mods/jei.jar": "sha256_hash", ... }
Server returns diff information
"""
client_ip = request.client.host if request.client else "unknown"
# Читаем тело запроса
try:
body = await request.json()
except Exception as e:
logger.error(f"Failed to parse JSON body: {e}")
raise HTTPException(400, "Invalid JSON body")
logger.info("Received diff request",
pack=pack_name,
client_files_count=len(client_files),
client_files_count=len(body),
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)
@@ -171,7 +208,7 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
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")
raise HTTPException(500, f"Internal server error: {str(e)}")
to_download = []
to_delete = []
@@ -179,9 +216,8 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
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)
client_hash = body.get(path)
if client_hash is None or client_hash != entry.hash:
url = f"/pack/{pack_name}/file/{path}"
to_download.append({
@@ -193,8 +229,7 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
if client_hash is not None:
to_update.append(path)
# Calculate what needs to be deleted
for path in client_files:
for path in body:
if path not in server_files:
to_delete.append(path)
@@ -213,76 +248,34 @@ async def get_pack_diff(pack_name: str, client_files: dict[str, str], request: R
"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)
# Исправлено: конвертируем datetime в строку при сериализации
return JSONResponse(
content=cached_meta.model_dump(),
content=cached_meta.model_dump(mode='json'),
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:
with open(meta_path, 'r', encoding='utf-8') as f:
meta_dict = json.load(f)
# Исправлено: используем model_validate для создания объекта
meta = PackMeta.model_validate(meta_dict)
# Update cache
manifest_cache[pack_name] = meta
logger.debug("Manifest served from disk",
@@ -290,16 +283,23 @@ async def get_pack_manifest(pack_name: str, request: Request):
version=meta.version,
client_ip=client_ip)
# Исправлено: конвертируем datetime в строку при сериализации
return JSONResponse(
content=meta_dict,
content=meta.model_dump(mode='json'),
headers={"X-Pack-Version": str(meta.version), "X-Cached": "false"}
)
@app.get("/pack/{pack_name}/file/{file_path:path}")
async def get_pack_file(pack_name: str, file_path: str, request: Request):
"""Get a file from a pack"""
full_path = PACKS_DIR / pack_name / file_path
client_ip = request.client.host if request.client else None
# Security: prevent path traversal
if ".." in file_path:
raise HTTPException(403, "Invalid file path")
if not full_path.exists() or not full_path.is_file():
logger.warning("File not found",
pack=pack_name,
@@ -316,51 +316,177 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
return FileResponse(full_path)
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
def get_current_launcher_version() -> str:
"""Get current launcher version from build.version file"""
version_file = BUILDS_DIR / "build.version"
if version_file.exists():
return version_file.read_text().strip()
return "1.0.0"
def get_available_zips() -> list:
"""Get list of available zip archives"""
if not BUILDS_DIR.exists():
return []
zips = []
for zip_file in BUILDS_DIR.glob("ZernMCLauncher-*.zip"):
version = zip_file.stem.replace("ZernMCLauncher-", "")
stat = zip_file.stat()
zips.append({
"version": version,
"filename": zip_file.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
})
zips.sort(key=lambda x: x["version"], reverse=True)
return zips
@app.get("/launcher/version")
async def get_launcher_version():
"""Возвращает информацию о текущей версии лаунчера"""
version_file = Path("builds/build.version")
"""Return launcher version information"""
version = get_current_launcher_version()
zips = get_available_zips()
version = "1.0.0"
if version_file.exists():
version = version_file.read_text().strip()
return {
response = {
"version": version,
"download_jar": "/launcher/download?type=jar",
"download_exe": "/launcher/download?type=exe",
"updated_at": datetime.utcnow().isoformat()
}
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
if jar_path.exists():
response["download_jar"] = "/launcher/download/jar"
response["jar_size"] = jar_path.stat().st_size
exe_path = BUILDS_DIR / "ZernMCLauncher.exe"
if exe_path.exists():
response["download_exe"] = "/launcher/download/exe"
response["exe_size"] = exe_path.stat().st_size
if zips:
response["download_zip"] = f"/launcher/download/zip/{zips[0]['filename']}"
response["zip_version"] = zips[0]["version"]
response["zip_size"] = zips[0]["size"]
response["all_zips"] = zips
return response
@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"
@app.get("/launcher/download/jar")
async def download_launcher_jar():
"""Download launcher JAR file"""
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
if not file_path.exists():
raise HTTPException(404, "Launcher file not found")
raise HTTPException(404, "JAR file not found")
return FileResponse(
path=file_path,
filename="ZernMCLauncher.jar",
media_type="application/java-archive"
)
@app.get("/launcher/download/exe")
async def download_launcher_exe():
"""Download launcher EXE file (Windows)"""
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
if not file_path.exists():
raise HTTPException(404, "EXE file not found")
return FileResponse(
path=file_path,
filename="ZernMCLauncher.exe",
media_type="application/vnd.microsoft.portable-executable"
)
@app.get("/launcher/download/zip/{filename}")
async def download_launcher_zip(filename: str):
"""Download specific launcher ZIP archive"""
if ".." in filename or not filename.startswith("ZernMCLauncher-") or not filename.endswith(".zip"):
raise HTTPException(400, "Invalid filename")
file_path = BUILDS_DIR / filename
if not file_path.exists():
raise HTTPException(404, "ZIP file not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream"
media_type="application/zip"
)
@app.get("/launcher/download/latest")
async def download_latest_launcher():
"""Download the latest launcher (prefer ZIP if available, fallback to JAR)"""
zips = get_available_zips()
if zips:
latest_zip = zips[0]["filename"]
return await download_launcher_zip(latest_zip)
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
if jar_path.exists():
return await download_launcher_jar()
raise HTTPException(404, "No launcher files available")
@app.get("/launcher/info")
async def get_launcher_full_info():
"""Full launcher information with all available files"""
version = get_current_launcher_version()
zips = get_available_zips()
info = {
"current_version": version,
"updated_at": datetime.utcnow().isoformat(),
"files": {
"jar": None,
"exe": None,
"zips": zips
},
"recommended": "zip" if zips else ("exe" if (BUILDS_DIR / "ZernMCLauncher.exe").exists() else "jar")
}
jar_path = BUILDS_DIR / "ZernMCLauncher.jar"
if jar_path.exists():
info["files"]["jar"] = {
"size": jar_path.stat().st_size,
"download_url": "/launcher/download/jar"
}
exe_path = BUILDS_DIR / "ZernMCLauncher.exe"
if exe_path.exists():
info["files"]["exe"] = {
"size": exe_path.stat().st_size,
"download_url": "/launcher/download/exe"
}
if zips:
info["files"]["latest_zip"] = zips[0]
info["files"]["download_latest"] = "/launcher/download/latest"
return info
# ====================== ЗАПУСК ======================
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)