Server: Add launcher meta system for incremental updates

- Create versions/ folder structure for new format builds
- Generate meta.json with SHA256 hashes for each file
- Add endpoints:
  - GET /launcher/meta - list all versions with meta
  - GET /launcher/meta/{version} - meta for specific version
  - POST /launcher/diff - get diff between local and server files
  - GET /launcher/file/{version}/{path} - download individual file
  - GET /launcher/download/zip/{version} - download full ZIP for new install
- Legacy builds (ZIP files) remain unchanged
This commit is contained in:
SashegDev
2026-05-07 18:40:00 +00:00
parent aaa19df5e4
commit e566703332
+186
View File
@@ -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"""