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
+2 -1
View File
@@ -102,8 +102,9 @@ def setup_logging():
structlog.processors.CallsiteParameter.MODULE,
structlog.processors.CallsiteParameter.FUNC_NAME,
}
),
),
_add_location,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
],
logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
+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)
+46 -83
View File
@@ -3,25 +3,17 @@ import os
from datetime import datetime
from pathlib import Path
import json
import aiofiles
from typing import Optional, Dict
import structlog
from models import PackMeta, FileEntry
import aiohttp
from typing import List, Optional
logger = structlog.get_logger(__name__)
PACKS_DIR = Path("packs")
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
MINECRAFT_VERSION_MANIFEST_URL = "https://launchermeta.mojang.com/mc/game/version_manifest.json"
FABRIC_META_URL = "https://meta.fabricmc.net/v2/versions"
FORGE_META_URL = "https://files.minecraftforge.net/net/minecraftforge/forge/"
# Cache for loaded manifests
_manifest_cache: Dict[str, PackMeta] = {}
@@ -32,102 +24,63 @@ def get_cached_manifest(pack_name: str) -> Optional[PackMeta]:
def get_meta_path(pack_name: str) -> Path:
return DATA_DIR / f"{pack_name}.meta"
async def calculate_sha256(file_path: Path) -> str:
def calculate_sha256_sync(file_path: Path) -> str:
"""Calculate SHA256 hash of a file (synchronous version)"""
hash_sha = hashlib.sha256()
async with aiofiles.open(file_path, 'rb') as f:
while chunk := await f.read(8192):
with open(file_path, 'rb') as f:
while chunk := f.read(8192):
hash_sha.update(chunk)
return hash_sha.hexdigest()
async def get_minecraft_versions():
"""Fetch available Minecraft versions from Mojang"""
from models import MinecraftVersion
async with aiohttp.ClientSession() as session:
async with session.get(MINECRAFT_VERSION_MANIFEST_URL) as response:
data = await response.json()
versions = []
for v in data.get("versions", []):
versions.append(MinecraftVersion(
version=v["id"],
type=v["type"],
release_time=datetime.fromisoformat(v["releaseTime"].replace('Z', '+00:00')),
url=v["url"]
))
return versions
async def get_fabric_versions(minecraft_version: str) -> List[str]:
"""Get available Fabric versions for specific Minecraft version"""
async with aiohttp.ClientSession() as session:
async with session.get(f"{FABRIC_META_URL}/loader") as response:
data = await response.json()
fabric_versions = []
for loader in data:
if loader.get("stable", True):
fabric_versions.append(loader["version"])
return fabric_versions
async def get_forge_versions(minecraft_version: str) -> List[str]:
"""Get available Forge versions for specific Minecraft version"""
async with aiohttp.ClientSession() as session:
async with session.get(FORGE_META_URL) as response:
# Forge API is more complex, simplified for now
return []
async def download_minecraft_version(version: str, target_path: Path) -> bool:
"""Download Minecraft version JSON and client jar"""
async with aiohttp.ClientSession() as session:
async with session.get(f"https://piston-meta.mojang.com/mc/game/{version}/{version}.json") as response:
if response.status == 200:
version_data = await response.json()
async with aiofiles.open(target_path / f"{version}.json", 'w') as f:
await f.write(json.dumps(version_data, indent=2))
downloads = version_data.get("downloads", {})
client_info = downloads.get("client", {})
if client_info:
client_url = client_info.get("url")
async with session.get(client_url) as client_response:
if client_response.status == 200:
async with aiofiles.open(target_path / f"{version}.jar", 'wb') as f:
async for chunk in client_response.content.iter_chunked(8192):
await f.write(chunk)
return True
return False
async def calculate_sha256(file_path: Path) -> str:
"""Calculate SHA256 hash of a file (async wrapper)"""
# Используем синхронную версию для простоты
return calculate_sha256_sync(file_path)
async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
"""Scan pack directory and update manifest if needed"""
pack_path = PACKS_DIR / pack_name
if not pack_path.exists() or not pack_path.is_dir():
raise FileNotFoundError(f"Pack {pack_name} not found")
meta_path = get_meta_path(pack_name)
current_meta: Optional[PackMeta] = None
# Check cache first
if not force_rescan and pack_name in _manifest_cache:
return _manifest_cache[pack_name]
# Load existing meta if available (синхронно)
if meta_path.exists():
async with aiofiles.open(meta_path, 'r', encoding='utf-8') as f:
data = json.loads(await f.read())
try:
try:
with open(meta_path, 'r', encoding='utf-8') as f:
data = json.load(f)
current_meta = PackMeta.model_validate(data)
except Exception as e:
logger.warning(f"Failed to validate existing meta for pack {pack_name}", error=str(e))
current_meta = None
except Exception as e:
logger.warning(f"Failed to load existing meta for pack {pack_name}: {e}")
current_meta = None
new_files: Dict[str, FileEntry] = {}
changed = False
# Get ignored directories
ignored_dirs = current_meta.ignored_dirs if current_meta else [
"resourcepacks", "shaderpacks", "saves", "logs",
"crash-reports", "screenshots", "journeymap", "config"
]
# Walk through pack directory
for root, dirs, files in os.walk(pack_path):
ignored = current_meta.ignored_dirs if current_meta else []
dirs[:] = [d for d in dirs if d not in ignored]
# Filter ignored directories
dirs[:] = [d for d in dirs if d not in ignored_dirs]
for file in files:
file_path = Path(root) / file
rel_path = file_path.relative_to(pack_path).as_posix()
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored):
# Skip if in ignored directory
if any(ignored_dir in rel_path.split('/') for ignored_dir in ignored_dirs):
continue
stat = file_path.stat()
@@ -143,48 +96,58 @@ async def scan_pack(pack_name: str, force_rescan: bool = False) -> PackMeta:
new_files[rel_path] = entry
# Check if file changed
if current_meta and (rel_path not in current_meta.files or
current_meta.files[rel_path].hash != file_hash):
changed = True
# Check if we need to update
if not current_meta or changed or len(new_files) != len(current_meta.files if current_meta else 0):
version = (current_meta.version + 1) if current_meta else 1
pack_config_path = pack_path / "instance.json"
# Load instance.json for pack metadata
minecraft_version = "1.20.4"
loader_type = "vanilla"
loader_version = None
pack_config_path = pack_path / "instance.json"
if pack_config_path.exists():
try:
async with aiofiles.open(pack_config_path, 'r', encoding='utf-8') as f:
config = json.loads(await f.read())
# Синхронное чтение конфига
with open(pack_config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
minecraft_version = config.get("minecraftVersion", minecraft_version)
loader_type = config.get("loaderType", loader_type)
loader_version = config.get("loaderVersion")
except Exception as e:
logger.warning(f"Failed to load instance.json for {pack_name}", error=str(e))
logger.warning(f"Failed to load instance.json for {pack_name}: {e}")
# Create new manifest
new_meta = PackMeta(
pack_name=pack_name,
version=version,
files=new_files,
updated_at=datetime.utcnow(),
ignored_dirs=current_meta.ignored_dirs if current_meta else [],
ignored_dirs=ignored_dirs,
minecraft_version=minecraft_version,
loader_type=loader_type,
loader_version=loader_version
)
async with aiofiles.open(meta_path, 'w', encoding='utf-8') as f:
await f.write(new_meta.model_dump_json(indent=2))
# Save to disk (синхронно)
with open(meta_path, 'w', encoding='utf-8') as f:
f.write(new_meta.model_dump_json(indent=2))
# Update cache
_manifest_cache[pack_name] = new_meta
logger.info(f"Pack updated: {pack_name} v{version}, {len(new_files)} files")
return new_meta
# No changes, use existing
if current_meta:
_manifest_cache[pack_name] = current_meta
return current_meta
return current_meta
# Should not happen
raise Exception(f"Failed to scan pack {pack_name}")