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 datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from typing import Optional
import httpx import httpx
import json import json
@@ -34,6 +35,7 @@ logger = structlog.get_logger(__name__)
manifest_cache = TTLCache(maxsize=100, ttl=300) manifest_cache = TTLCache(maxsize=100, ttl=300)
BUILDS_DIR = Path("builds") BUILDS_DIR = Path("builds")
VERSIONS_DIR = BUILDS_DIR / "versions"
# IP Filtering Configuration # IP Filtering Configuration
import os import os
@@ -794,6 +796,94 @@ def is_new_format(filename: str) -> bool:
return filename.startswith("ZernMC-win-") 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: def get_available_zips() -> list:
"""Get list of available zip archives (new format only)""" """Get list of available zip archives (new format only)"""
if not BUILDS_DIR.exists(): 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") @app.get("/launcher/info")
async def get_launcher_full_info(): async def get_launcher_full_info():
"""Full launcher information with all available files""" """Full launcher information with all available files"""