Launcher UI redesign + server mirror sync + file download optimization
This commit is contained in:
+370
-40
@@ -12,7 +12,7 @@ import json
|
||||
import structlog
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
|
||||
# Disable httpx debug logging
|
||||
@@ -22,13 +22,18 @@ logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||
from models import PackMeta
|
||||
from middleware import LoggingMiddleware
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode, run_sync_mode
|
||||
from log_manager import init_logging
|
||||
|
||||
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||
from roles import Permissions, has_permission
|
||||
from admin_router import router as admin_router
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# Cache for manifests - expires after 5 minutes
|
||||
@@ -37,6 +42,18 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
"main": "http://87.120.187.36:1582",
|
||||
"mirror-1": "http://212.22.82.243:1582",
|
||||
}
|
||||
|
||||
# Server role: "main" or "mirror"
|
||||
SERVER_ROLE = os.environ.get("SERVER_ROLE", "main")
|
||||
MAIN_SERVER_URL = os.environ.get("MAIN_SERVER_URL", "") # For mirrors to sync from
|
||||
SYNC_API_KEY = os.environ.get("SYNC_API_KEY", "changeme") # API key for mirror sync
|
||||
MASTER_KEY = os.environ.get("MASTER_KEY", "sashegdevsupeddevepta") # Master key for admin/mirror
|
||||
|
||||
# IP Filtering Configuration
|
||||
import os
|
||||
import middleware as mw
|
||||
@@ -147,7 +164,51 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(f"Failed to scan pack: {pack_dir.name} - {e}", exc_info=True)
|
||||
|
||||
logger.info("All packs ready. Server is running.")
|
||||
|
||||
|
||||
# Mirror sync with main server
|
||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||
logger.info(f"Mirror mode: syncing from {MAIN_SERVER_URL}")
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
||||
main_data = resp.json()
|
||||
main_version = main_data.get("version")
|
||||
logger.info(f"Main server version: {main_version}")
|
||||
|
||||
# Get sync manifest with API key
|
||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||
resp = await client.get(f"{MAIN_SERVER_URL}/launcher/sync/{main_version}", headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Sync failed: {resp.status_code} - {resp.text}")
|
||||
raise Exception(f"Sync auth failed: {resp.status_code}")
|
||||
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Need to sync {len(sync_data.get('files', []))} files")
|
||||
|
||||
# Download each file
|
||||
for f in sync_data.get("files", []):
|
||||
file_path = BUILDS_DIR / f["path"]
|
||||
if not file_path.exists():
|
||||
logger.info(f"Syncing: {f['path']}")
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Download file
|
||||
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
|
||||
resp = await client.get(file_url, headers=headers)
|
||||
file_path.write_bytes(resp.content)
|
||||
logger.debug(f"Downloaded: {f['path']}")
|
||||
|
||||
# Delete removed files
|
||||
for deleted_file in sync_data.get("delete", []):
|
||||
del_path = BUILDS_DIR / deleted_file
|
||||
if del_path.exists():
|
||||
del_path.unlink()
|
||||
logger.info(f"Deleted: {deleted_file}")
|
||||
|
||||
logger.info("Mirror sync complete")
|
||||
except Exception as e:
|
||||
logger.warning(f"Mirror sync failed: {e}")
|
||||
|
||||
# Scan launcher versions and generate meta
|
||||
logger.info("Scanning launcher versions...")
|
||||
|
||||
@@ -172,6 +233,54 @@ async def lifespan(app: FastAPI):
|
||||
global proxy_client
|
||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||
|
||||
# Start background sync task for mirrors
|
||||
if SERVER_ROLE == "mirror" and MAIN_SERVER_URL:
|
||||
import asyncio
|
||||
|
||||
async def periodic_sync():
|
||||
sync_interval = 7200 # 2 hours
|
||||
while True:
|
||||
await asyncio.sleep(sync_interval)
|
||||
try:
|
||||
logger.info("Periodic mirror sync started...")
|
||||
headers = {"X-Sync-Key": SYNC_API_KEY}
|
||||
|
||||
resp = await proxy_client.get(f"{MAIN_SERVER_URL}/launcher/version")
|
||||
main_data = resp.json()
|
||||
main_version = main_data.get("version")
|
||||
|
||||
resp = await proxy_client.get(
|
||||
f"{MAIN_SERVER_URL}/launcher/sync/{main_version}",
|
||||
headers=headers
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"Periodic sync failed: {resp.status_code}")
|
||||
continue
|
||||
|
||||
sync_data = resp.json()
|
||||
logger.info(f"Periodic sync: {len(sync_data.get('files', []))} files")
|
||||
|
||||
for f in sync_data.get("files", []):
|
||||
file_path = BUILDS_DIR / f["path"]
|
||||
if not file_path.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_url = f"{MAIN_SERVER_URL}/launcher/sync/{main_version}/file/{f['path']}"
|
||||
resp = await proxy_client.get(file_url, headers=headers)
|
||||
file_path.write_bytes(resp.content)
|
||||
logger.debug(f"Synced: {f['path']}")
|
||||
|
||||
for deleted_file in sync_data.get("delete", []):
|
||||
del_path = BUILDS_DIR / deleted_file
|
||||
if del_path.exists():
|
||||
del_path.unlink()
|
||||
logger.info(f"Deleted: {deleted_file}")
|
||||
|
||||
logger.info("Periodic mirror sync complete")
|
||||
except Exception as e:
|
||||
logger.warning(f"Periodic sync error: {e}")
|
||||
|
||||
asyncio.create_task(periodic_sync())
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup proxy client
|
||||
@@ -512,6 +621,136 @@ app = FastAPI(title="ZernMC Launcher Server", lifespan=lifespan)
|
||||
# Add Logging middleware
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
|
||||
# ====================== ОПТИМИЗАЦИЯ ЗАГРУЗКИ ФАЙЛОВ ======================
|
||||
|
||||
class CacheControlMiddleware:
|
||||
"""Middleware for caching static and large files"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Skip caching for dynamic endpoints
|
||||
skip_cache = any(p in path for p in ["/api/", "/auth/", "/login", "/launch", "/install"])
|
||||
if skip_cache:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Add caching headers for static files
|
||||
async def send_wrapper(status, headers, *args, **kwargs):
|
||||
# Add cache headers for static files
|
||||
cache_headers = [
|
||||
(b"cache-control", b"public, max-age=86400"), # 24 hours
|
||||
(b"etag", b'"file-etag"'),
|
||||
]
|
||||
headers = list(headers) + cache_headers
|
||||
await send(status, headers, *args, **kwargs)
|
||||
|
||||
# Use original send
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
app.add_middleware(CacheControlMiddleware)
|
||||
|
||||
|
||||
# Cache for file hashes (ETag)
|
||||
file_etag_cache = TTLCache(maxsize=1000, ttl=3600)
|
||||
|
||||
|
||||
async def get_etag_for_file(file_path: Path) -> str:
|
||||
"""Get or calculate ETag for file"""
|
||||
cache_key = str(file_path)
|
||||
|
||||
if cache_key in file_etag_cache:
|
||||
return file_etag_cache[cache_key]
|
||||
|
||||
# Calculate from file size + mtime
|
||||
stat = file_path.stat()
|
||||
etag = f'"{stat.st_size}-{stat.st_mtime}"'
|
||||
file_etag_cache[cache_key] = etag
|
||||
|
||||
return etag
|
||||
|
||||
|
||||
async def send_file_async(
|
||||
file_path: Path,
|
||||
request: Request,
|
||||
content_type: str = None,
|
||||
cache: bool = True
|
||||
):
|
||||
"""Optimized async file serving with Range support"""
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
|
||||
# Determine content type
|
||||
if content_type is None:
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
|
||||
|
||||
# Check for Range header (for resumable downloads)
|
||||
range_header = request.headers.get("range")
|
||||
|
||||
if range_header:
|
||||
# Parse Range header
|
||||
match = re.match(r"bytes=(\d+)-(\d+)?", range_header)
|
||||
if match:
|
||||
start = int(match.group(1))
|
||||
end = min(file_size - 1, int(match.group(2)) if match.group(2) else file_size - 1)
|
||||
else:
|
||||
start, end = 0, file_size - 1
|
||||
|
||||
content_length = end - start + 1
|
||||
|
||||
# Read chunk asynchronously
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
await f.seek(start)
|
||||
chunk = await f.read(content_length)
|
||||
|
||||
# Return 206 Partial Content
|
||||
return StreamingResponse(
|
||||
iter([chunk]),
|
||||
status_code=206,
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(content_length),
|
||||
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Return full file with streaming
|
||||
async def file_iterator():
|
||||
async with aiofiles.open(file_path, "rb") as f:
|
||||
while True:
|
||||
chunk = await f.read(65536) # 64KB chunks
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
# Calculate ETag
|
||||
etag = await get_etag_for_file(file_path)
|
||||
|
||||
return StreamingResponse(
|
||||
file_iterator(),
|
||||
media_type=content_type,
|
||||
headers={
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(file_size),
|
||||
"Cache-Control": "public, max-age=86400" if cache else "no-cache",
|
||||
"ETag": etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
)
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
@@ -583,18 +822,23 @@ async def activate_pass_page():
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
||||
|
||||
@app.get("/packs")
|
||||
async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||
"""List all available packs - требует проходку для просмотра"""
|
||||
|
||||
# Проверяем, есть ли право на просмотр сборок
|
||||
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Для просмотра сборок требуется активная проходка"
|
||||
)
|
||||
|
||||
async def list_packs(request: Request):
|
||||
"""List all available packs - requires auth or master key for mirrors"""
|
||||
# Check for master key
|
||||
master_key = request.headers.get("X-Master-Key")
|
||||
if master_key == MASTER_KEY:
|
||||
# Master key - allow access
|
||||
pass
|
||||
else:
|
||||
# Normal auth required
|
||||
current_user = await get_current_user(request)
|
||||
if not current_user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||
raise HTTPException(403, "Requires active pass")
|
||||
|
||||
packs = []
|
||||
|
||||
|
||||
for pack_dir in PACKS_DIR.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
meta_path = DATA_DIR / f"{pack_dir.name}.meta"
|
||||
@@ -770,13 +1014,13 @@ async def get_pack_file(pack_name: str, file_path: str, request: Request):
|
||||
client_ip=client_ip)
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
logger.info("Serving file",
|
||||
pack=pack_name,
|
||||
file=file_path,
|
||||
logger.info("Serving file",
|
||||
pack=pack_name,
|
||||
file=file_path,
|
||||
size=full_path.stat().st_size,
|
||||
client_ip=client_ip)
|
||||
|
||||
return FileResponse(full_path, direct_passthrough=True)
|
||||
|
||||
return await send_file_async(full_path, request, cache=True)
|
||||
|
||||
|
||||
# ====================== ЭНДПОИНТЫ ДЛЯ ЛАУНЧЕРА ======================
|
||||
@@ -877,11 +1121,14 @@ def generate_launcher_builds_meta():
|
||||
logger.warning(f"Failed to generate launcher meta: {e}")
|
||||
return
|
||||
|
||||
mirrors = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
|
||||
|
||||
meta = {
|
||||
"version": version,
|
||||
"type": "builds",
|
||||
"release_date": datetime.utcnow().isoformat(),
|
||||
"files": files
|
||||
"files": files,
|
||||
"mirrors": mirrors
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1128,16 +1375,16 @@ async def get_launcher_version():
|
||||
|
||||
|
||||
@app.get("/launcher/download/jar")
|
||||
async def download_launcher_jar():
|
||||
async def download_launcher_jar(request: Request = None):
|
||||
"""Download launcher JAR file"""
|
||||
# Prefer new shaded JAR, fallback to old
|
||||
file_path = BUILDS_DIR / "zernmclauncher.jar"
|
||||
if not file_path.exists():
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.jar"
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "JAR file not found")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/java-archive", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="zernmclauncher.jar",
|
||||
@@ -1146,13 +1393,16 @@ async def download_launcher_jar():
|
||||
|
||||
|
||||
@app.get("/launcher/download/exe")
|
||||
async def download_launcher_exe():
|
||||
async def download_launcher_exe(request: Request = None):
|
||||
"""Download launcher EXE file (Windows)"""
|
||||
file_path = BUILDS_DIR / "ZernMCLauncher.exe"
|
||||
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(404, "EXE file not found")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/vnd.microsoft.portable-executable", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename="ZernMCLauncher.exe",
|
||||
@@ -1161,17 +1411,20 @@ async def download_launcher_exe():
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{filename}")
|
||||
async def download_launcher_zip(filename: str):
|
||||
async def download_launcher_zip(filename: str, request: Request = None):
|
||||
"""Download specific launcher ZIP archive"""
|
||||
valid_patterns = ["ZernMCLauncher-", "ZernMC-win-"]
|
||||
if ".." in filename or not any(filename.startswith(p) for p in valid_patterns) 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")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(file_path, request, content_type="application/zip", cache=True)
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
@@ -1233,12 +1486,82 @@ async def get_launcher_meta_list():
|
||||
versions = get_launcher_versions()
|
||||
return {
|
||||
"versions": [
|
||||
{"version": v["version"], "meta": v["meta"]}
|
||||
{"version": v["version"], "meta": v["meta"]}
|
||||
for v in versions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/launcher/mirrors")
|
||||
async def get_launcher_mirrors():
|
||||
"""Get list of available mirrors"""
|
||||
mirrors_list = [{"name": name, "url": url} for name, url in LAUNCHER_MIRRORS.items()]
|
||||
return {"mirrors": mirrors_list}
|
||||
|
||||
|
||||
# ====================== SYNC FOR MIRRORS ======================
|
||||
|
||||
def verify_sync_api_key(request: Request):
|
||||
"""Verify API key for sync endpoints"""
|
||||
api_key = request.headers.get("X-Sync-Key")
|
||||
if not api_key or api_key != SYNC_API_KEY:
|
||||
raise HTTPException(401, "Invalid or missing sync API key")
|
||||
|
||||
|
||||
@app.get("/launcher/sync/{version}")
|
||||
async def get_sync_manifest(version: str, request: Request):
|
||||
"""Get sync manifest for mirror servers - returns files to download/delete"""
|
||||
verify_sync_api_key(request)
|
||||
|
||||
if SERVER_ROLE != "main":
|
||||
raise HTTPException(403, "Sync only available on main server")
|
||||
|
||||
# Check for incremental sync (if-modified-since)
|
||||
last_sync = request.headers.get("If-Modified-Since")
|
||||
|
||||
# Get server meta
|
||||
meta = get_launcher_version_meta(version)
|
||||
if not meta:
|
||||
raise HTTPException(404, f"Version {version} not found")
|
||||
|
||||
# Build sync response
|
||||
sync_data = {
|
||||
"version": version,
|
||||
"files": [],
|
||||
"delete": [], # Files that were removed from main
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
for f in meta.get("files", []):
|
||||
sync_data["files"].append({
|
||||
"path": f["path"],
|
||||
"size": f["size"],
|
||||
"hash": f["hash"],
|
||||
"url": f"/launcher/file/{version}/{f['path']}"
|
||||
})
|
||||
|
||||
return sync_data
|
||||
|
||||
|
||||
@app.get("/launcher/sync/{version}/file/{file_path:path}")
|
||||
async def sync_download_file(version: str, file_path: str, request: Request):
|
||||
"""Download file for mirror sync"""
|
||||
verify_sync_api_key(request)
|
||||
|
||||
if SERVER_ROLE != "main":
|
||||
raise HTTPException(403, "Sync only available on main server")
|
||||
|
||||
full_path = BUILDS_DIR / file_path
|
||||
|
||||
if ".." in file_path:
|
||||
raise HTTPException(403, "Invalid path")
|
||||
|
||||
if not full_path.exists():
|
||||
raise HTTPException(404, "File not found")
|
||||
|
||||
return await send_file_async(full_path, request, cache=False)
|
||||
|
||||
|
||||
@app.get("/launcher/meta/{version}")
|
||||
async def get_launcher_version_meta_handler(version: str):
|
||||
"""Get meta for specific launcher version"""
|
||||
@@ -1305,18 +1628,22 @@ async def get_launcher_file(version: str, file_path: str, request: Request):
|
||||
full_path = alt_path
|
||||
else:
|
||||
raise HTTPException(404, "File not found: " + file_path)
|
||||
|
||||
return FileResponse(full_path, direct_passthrough=True)
|
||||
|
||||
return await send_file_async(full_path, request, cache=True)
|
||||
|
||||
|
||||
@app.get("/launcher/download/zip/{version}")
|
||||
async def download_launcher_zip_version(version: str):
|
||||
async def download_launcher_zip_version(version: str, request: Request = None):
|
||||
"""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")
|
||||
|
||||
|
||||
if request:
|
||||
return await send_file_async(zip_path, request, content_type="application/zip", cache=True)
|
||||
|
||||
# Fallback without request
|
||||
return FileResponse(
|
||||
path=zip_path,
|
||||
filename=f"ZernMC-win-{version}.zip",
|
||||
@@ -1776,10 +2103,13 @@ async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
|
||||
if args.test:
|
||||
import asyncio
|
||||
asyncio.run(run_test_mode())
|
||||
elif args.sync:
|
||||
import asyncio
|
||||
asyncio.run(run_sync_mode())
|
||||
elif args.dev:
|
||||
run_development_mode(args.host, args.port, args.reload)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user