diff --git a/server/main.py b/server/main.py index 0a701da..039d77c 100644 --- a/server/main.py +++ b/server/main.py @@ -5,6 +5,7 @@ from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from urllib.parse import urlparse +from typing import Optional import httpx import json @@ -34,6 +35,7 @@ logger = structlog.get_logger(__name__) manifest_cache = TTLCache(maxsize=100, ttl=300) BUILDS_DIR = Path("builds") +VERSIONS_DIR = BUILDS_DIR / "versions" # IP Filtering Configuration import os @@ -794,6 +796,94 @@ def is_new_format(filename: str) -> bool: return filename.startswith("ZernMC-win-") +# ====================== ЛАУНЧЕР МЕТА СИСТЕМА ====================== + +def calculate_file_hash(file_path: Path) -> str: + """Calculate SHA256 hash of a file""" + import hashlib + hash_sha = hashlib.sha256() + with open(file_path, 'rb') as f: + while chunk := f.read(8192): + hash_sha.update(chunk) + return hash_sha.hexdigest() + + +def scan_launcher_version(version: str) -> Optional[dict]: + """Scan a launcher version directory and return meta""" + version_path = VERSIONS_DIR / version + + if not version_path.exists() or not version_path.is_dir(): + return None + + meta_path = version_path / "meta.json" + + # Check cache first + if meta_path.exists(): + try: + import json + with open(meta_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + pass + + # Generate meta + files = [] + for file_path in version_path.rglob("*"): + if file_path.is_file() and file_path.name != "meta.json": + rel_path = str(file_path.relative_to(version_path)) + stat = file_path.stat() + file_hash = calculate_file_hash(file_path) + files.append({ + "path": rel_path, + "size": stat.st_size, + "hash": f"sha256:{file_hash}" + }) + + meta = { + "version": version, + "type": "new", + "release_date": datetime.utcnow().isoformat(), + "files": files + } + + # Save meta + try: + import json + with open(meta_path, 'w', encoding='utf-8') as f: + json.dump(meta, f, indent=2) + except Exception as e: + logger.warning(f"Failed to save launcher meta for {version}: {e}") + + return meta + + +def get_launcher_versions() -> list: + """Get list of available launcher versions with meta""" + if not VERSIONS_DIR.exists(): + return [] + + versions = [] + for version_dir in VERSIONS_DIR.iterdir(): + if version_dir.is_dir(): + meta = scan_launcher_version(version_dir.name) + if meta: + versions.append({ + "version": version_dir.name, + "meta": meta + }) + + versions.sort(key=lambda x: x["version"], reverse=True) + return versions + + +def get_launcher_version_meta(version: str) -> Optional[dict]: + """Get meta for specific launcher version""" + return scan_launcher_version(version) + + +# ====================== END ЛАУНЧЕР МЕТА СИСТЕМА ====================== + + def get_available_zips() -> list: """Get list of available zip archives (new format only)""" if not BUILDS_DIR.exists(): @@ -998,6 +1088,102 @@ async def download_launcher_zip(filename: str): ) +# ====================== ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ====================== + +@app.get("/launcher/meta") +async def get_launcher_meta_list(): + """Get list of all launcher versions with meta (new format)""" + versions = get_launcher_versions() + return { + "versions": [ + {"version": v["version"], "meta": v["meta"]} + for v in versions + ] + } + + +@app.get("/launcher/meta/{version}") +async def get_launcher_version_meta(version: str): + """Get meta for specific launcher version""" + meta = get_launcher_version_meta(version) + if not meta: + raise HTTPException(404, f"Version {version} not found or not in new format") + return meta + + +@app.post("/launcher/diff") +async def get_launcher_diff(request: Request): + """Get diff for launcher update - compare local files with server version""" + body = await request.json() + + # Get latest version + versions = get_launcher_versions() + if not versions: + raise HTTPException(404, "No versions available") + + latest = versions[0] + meta = latest["meta"] + + # Build hash map from client + client_hashes = body # {filename: hash, ...} + + to_download = [] + to_delete = [] + + # Find new/updated files + server_files = {f["path"]: f for f in meta["files"]} + + for path, file_info in server_files.items(): + if path not in client_hashes: + to_download.append(file_info) + elif client_hashes[path] != file_info["hash"]: + to_download.append(file_info) + + # Find deleted files (files on server but not in client) + for path in client_hashes: + if path not in server_files: + to_delete.append(path) + + return { + "version": meta["version"], + "to_download": to_download, + "to_delete": to_delete + } + + +@app.get("/launcher/file/{version}/{file_path:path}") +async def get_launcher_file(version: str, file_path: str, request: Request): + """Download a specific file from a launcher version""" + full_path = VERSIONS_DIR / version / file_path + + # 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(): + raise HTTPException(404, "File not found") + + return FileResponse(full_path, direct_passthrough=True) + + +@app.get("/launcher/download/zip/{version}") +async def download_launcher_zip_version(version: str): + """Download full ZIP for specific version (for new installs)""" + zip_path = BUILDS_DIR / f"ZernMC-win-{version}.zip" + + if not zip_path.exists(): + raise HTTPException(404, f"ZIP for version {version} not found") + + return FileResponse( + path=zip_path, + filename=f"ZernMC-win-{version}.zip", + media_type="application/zip" + ) + + +# ====================== END ЛАУНЧЕР МЕТА ЭНДПОИНТЫ ====================== + + @app.get("/launcher/info") async def get_launcher_full_info(): """Full launcher information with all available files"""