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:
+186
@@ -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"""
|
||||||
|
|||||||
Reference in New Issue
Block a user