DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню
This commit is contained in:
+331
-2
@@ -1,13 +1,15 @@
|
||||
# admin_router.py
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request, status, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
import structlog
|
||||
import time
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from auth import get_db, require_role, log_audit, get_current_user
|
||||
from auth import get_db, require_role, log_audit, get_current_user, hash_password
|
||||
from roles import (
|
||||
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
|
||||
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||
@@ -548,6 +550,333 @@ async def get_admin_stats(
|
||||
}
|
||||
|
||||
|
||||
# ====================== WHITELIST MODS ======================
|
||||
|
||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt"
|
||||
|
||||
|
||||
def _read_mods_list() -> list[dict]:
|
||||
"""Parse mods.txt into list of {name, size, hash}"""
|
||||
if not WHITELIST_MODS_FILE.exists():
|
||||
return []
|
||||
mods = []
|
||||
for line in WHITELIST_MODS_FILE.read_text("utf-8").strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("::")
|
||||
if len(parts) >= 3:
|
||||
mods.append({
|
||||
"name": parts[0].strip(),
|
||||
"size": int(parts[1].strip()),
|
||||
"hash": parts[2].strip()
|
||||
})
|
||||
return mods
|
||||
|
||||
|
||||
def _write_mods_list(mods: list[dict]):
|
||||
"""Write mods list to mods.txt"""
|
||||
lines = [f"{m['name']} :: {m['size']} :: {m['hash']}" for m in mods]
|
||||
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
WHITELIST_MODS_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
@router.get("/whitelist/mods")
|
||||
async def admin_list_whitelist_mods(current_user: dict = Depends(require_role(ROLE_ELDER))):
|
||||
"""List all whitelist mods (JSON)"""
|
||||
return {"mods": _read_mods_list()}
|
||||
|
||||
|
||||
@router.post("/whitelist/mods")
|
||||
async def add_whitelist_mod(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Upload a mod file — auto-computes hash, records size, adds to whitelist"""
|
||||
if not file.filename or not file.filename.endswith(".jar"):
|
||||
raise HTTPException(400, "Only .jar files are allowed")
|
||||
|
||||
contents = await file.read()
|
||||
if len(contents) == 0:
|
||||
raise HTTPException(400, "Empty file")
|
||||
|
||||
file_hash = hashlib.sha256(contents).hexdigest()
|
||||
file_size = len(contents)
|
||||
|
||||
WHITELIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
mod_path = WHITELIST_DIR / file_hash
|
||||
if mod_path.exists():
|
||||
raise HTTPException(409, f"Mod with hash {file_hash} already exists")
|
||||
|
||||
mod_path.write_bytes(contents)
|
||||
|
||||
mods = _read_mods_list()
|
||||
mods.append({"name": file.filename, "size": file_size, "hash": file_hash})
|
||||
_write_mods_list(mods)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "whitelist_add", f"Added {file.filename} ({file_hash})", ip)
|
||||
|
||||
return {"success": True, "name": file.filename, "size": file_size, "hash": file_hash}
|
||||
|
||||
|
||||
@router.delete("/whitelist/mods/{mod_hash}")
|
||||
async def remove_whitelist_mod(
|
||||
mod_hash: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Remove a mod from whitelist by hash"""
|
||||
if ".." in mod_hash:
|
||||
raise HTTPException(400, "Invalid hash")
|
||||
|
||||
mod_path = WHITELIST_DIR / mod_hash
|
||||
deleted_file = False
|
||||
if mod_path.exists() and mod_path.is_file():
|
||||
mod_path.unlink()
|
||||
deleted_file = True
|
||||
|
||||
mods = _read_mods_list()
|
||||
filtered = [m for m in mods if m["hash"] != mod_hash]
|
||||
if len(filtered) == len(mods) and not deleted_file:
|
||||
raise HTTPException(404, "Mod not found")
|
||||
|
||||
_write_mods_list(filtered)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "whitelist_remove", f"Removed mod with hash {mod_hash}", ip)
|
||||
|
||||
return {"success": True, "hash": mod_hash, "file_deleted": deleted_file}
|
||||
|
||||
|
||||
# ====================== ADMIN CLIENTS, USERS, PASSES ======================
|
||||
|
||||
@router.get("/clients")
|
||||
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
||||
"""List online clients"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
|
||||
FROM users u
|
||||
LEFT JOIN user_status us ON u.id = us.user_id
|
||||
LEFT JOIN user_sessions s ON u.id = s.user_id AND s.is_active = 1
|
||||
WHERE us.is_online = 1
|
||||
GROUP BY u.id
|
||||
ORDER BY us.last_seen DESC
|
||||
""").fetchall()
|
||||
clients = []
|
||||
for row in rows:
|
||||
clients.append({
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"online": bool(row["is_online"]),
|
||||
"current_pack": row["current_pack"] or "",
|
||||
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
|
||||
"ip": row["ip_address"] or ""
|
||||
})
|
||||
return {"success": True, "clients": clients}
|
||||
|
||||
|
||||
class UserSearchRequest(BaseModel):
|
||||
search: str
|
||||
|
||||
|
||||
@router.post("/users/search")
|
||||
async def admin_search_users(
|
||||
req: UserSearchRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
):
|
||||
"""Search users by username"""
|
||||
if not req.search.strip():
|
||||
return {"users": []}
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT id, username, uuid, role, created_at, last_login, is_active
|
||||
FROM users WHERE username LIKE ?
|
||||
ORDER BY username LIMIT 20
|
||||
""", (f"%{req.search.strip()}%",)).fetchall()
|
||||
users = []
|
||||
for row in rows:
|
||||
users.append({
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"uuid": row["uuid"],
|
||||
"role": row["role"],
|
||||
"role_name": ROLE_NAMES.get(row["role"], "Unknown"),
|
||||
"created_at": row["created_at"],
|
||||
"last_login": row["last_login"],
|
||||
"is_active": bool(row["is_active"])
|
||||
})
|
||||
return {"success": True, "users": users}
|
||||
|
||||
|
||||
class RoleUpdateRequest(BaseModel):
|
||||
user_id: int
|
||||
role: int = Field(..., ge=0, le=4)
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/users/role")
|
||||
async def admin_update_user_role(
|
||||
body: RoleUpdateRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Update user role"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username, role FROM users WHERE id = ?",
|
||||
(body.user_id,)
|
||||
).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Cannot change creator role")
|
||||
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Cannot modify equal or higher role")
|
||||
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Cannot assign role above your own")
|
||||
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||
raise HTTPException(403, "Only creator can assign Elder")
|
||||
|
||||
old_role = target["role"]
|
||||
conn.execute("UPDATE users SET role = ? WHERE id = ?", (body.role, body.user_id))
|
||||
conn.commit()
|
||||
|
||||
log_audit(current_user["id"], "role_change",
|
||||
f"Changed role of {target['username']} from {old_role} to {body.role}", ip)
|
||||
|
||||
return {"success": True, "user_id": body.user_id, "username": target["username"],
|
||||
"old_role": old_role, "new_role": body.role}
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
user_id: int
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/users/reset-password")
|
||||
async def admin_reset_password(
|
||||
body: ResetPasswordRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Reset user password"""
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
new_password = secrets.token_hex(8)
|
||||
pw_hash = hash_password(new_password)
|
||||
with get_db() as conn:
|
||||
target = conn.execute(
|
||||
"SELECT id, username FROM users WHERE id = ?",
|
||||
(body.user_id,)
|
||||
).fetchone()
|
||||
if not target:
|
||||
raise HTTPException(404, "User not found")
|
||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?",
|
||||
(pw_hash, body.user_id))
|
||||
conn.commit()
|
||||
log_audit(current_user["id"], "password_reset",
|
||||
f"Reset password for {target['username']}", ip)
|
||||
return {"success": True, "new_password": new_password, "username": target["username"]}
|
||||
|
||||
|
||||
@router.get("/passes")
|
||||
async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
|
||||
"""List all passes"""
|
||||
with get_db() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT p.code, p.is_active, p.owner, p.activated_by, p.activated_at,
|
||||
p.expires_at, p.max_uses, p.uses, u.username as activated_username
|
||||
FROM passes p
|
||||
LEFT JOIN users u ON p.activated_by = u.id
|
||||
ORDER BY p.activated_at DESC
|
||||
""").fetchall()
|
||||
passes = []
|
||||
for row in rows:
|
||||
passes.append({
|
||||
"code": row["code"],
|
||||
"owner": row["owner"],
|
||||
"is_active": bool(row["is_active"]),
|
||||
"activated_by": row["activated_by"],
|
||||
"activated_username": row["activated_username"],
|
||||
"activated_at": row["activated_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
"max_uses": row["max_uses"],
|
||||
"uses": row["uses"]
|
||||
})
|
||||
return {"success": True, "passes": passes}
|
||||
|
||||
|
||||
@router.post("/whitelist/mods/{mod_hash}/delete")
|
||||
async def admin_delete_whitelist_mod_adapter(
|
||||
mod_hash: str,
|
||||
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Delete whitelist mod (adapter for POST-based deletion)"""
|
||||
return await remove_whitelist_mod(mod_hash, current_user, request)
|
||||
|
||||
|
||||
# ====================== ADMIN NEWS ======================
|
||||
|
||||
ADMIN_NEWS_DIR = Path(__file__).parent / "news"
|
||||
|
||||
|
||||
class CreateNewsRequest(BaseModel):
|
||||
title: str
|
||||
type: str = "Update"
|
||||
version: str = ""
|
||||
body: str
|
||||
|
||||
|
||||
class DeleteNewsRequest(BaseModel):
|
||||
index: int
|
||||
|
||||
|
||||
@router.post("/news")
|
||||
async def admin_create_news(
|
||||
body: CreateNewsRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Create a news item"""
|
||||
if not body.title.strip() or not body.body.strip():
|
||||
raise HTTPException(400, "Title and body are required")
|
||||
ADMIN_NEWS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
news_id = str(int(time.time()))
|
||||
file_path = ADMIN_NEWS_DIR / f"{news_id}.txt"
|
||||
content = f"{body.title.strip()}\n{body.type.strip()}\n{body.version.strip()}\n{body.body.strip()}"
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "news_create", f"Created news: {body.title}", ip)
|
||||
return {"success": True, "id": news_id}
|
||||
|
||||
|
||||
@router.post("/news/delete")
|
||||
async def admin_delete_news(
|
||||
body: DeleteNewsRequest,
|
||||
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||
request: Request = None,
|
||||
):
|
||||
"""Delete a news item by displayed index (0 = newest)"""
|
||||
if not ADMIN_NEWS_DIR.exists():
|
||||
raise HTTPException(404, "No news to delete")
|
||||
files = sorted(ADMIN_NEWS_DIR.iterdir())
|
||||
news_files = [f for f in files if f.is_file() and f.suffix == ".txt"]
|
||||
if body.index < 0 or body.index >= len(news_files):
|
||||
raise HTTPException(404, "News not found")
|
||||
# Displayed list is reversed (newest first), so map index
|
||||
target = news_files[len(news_files) - 1 - body.index]
|
||||
target.unlink()
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
log_audit(current_user["id"], "news_delete", f"Deleted news: {target.stem}", ip)
|
||||
return {"success": True, "id": target.stem}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_my_info(current_user: dict = Depends(get_current_user)):
|
||||
"""Информация о текущем пользователе с правами"""
|
||||
|
||||
+63
-25
@@ -43,6 +43,7 @@ manifest_cache = TTLCache(maxsize=100, ttl=300)
|
||||
|
||||
BUILDS_DIR = Path("builds")
|
||||
VERSIONS_DIR = BUILDS_DIR / "versions"
|
||||
WHITELIST_DIR = Path(__file__).parent / "whitelist"
|
||||
|
||||
# Mirror configuration
|
||||
LAUNCHER_MIRRORS = {
|
||||
@@ -1256,36 +1257,42 @@ def generate_launcher_builds_meta():
|
||||
|
||||
meta_path = BUILDS_DIR / "meta.json"
|
||||
|
||||
# Check if meta exists and is fresh
|
||||
if meta_path.exists():
|
||||
try:
|
||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||
existing = json.load(f)
|
||||
if existing.get("version") == version:
|
||||
logger.debug("Launcher meta.json already exists and is current")
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Always regenerate to pick up file changes (new JS, CSS, etc.)
|
||||
# The version check alone is insufficient — same version can have different files
|
||||
|
||||
|
||||
# Generate new meta
|
||||
files = []
|
||||
try:
|
||||
for file_path in BUILDS_DIR.rglob("*"):
|
||||
if file_path.is_file() and file_path.name not in ["meta.json"]:
|
||||
rel_path = str(file_path.relative_to(BUILDS_DIR))
|
||||
stat = file_path.stat()
|
||||
|
||||
# Calculate hash
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256_hash.update(chunk)
|
||||
|
||||
files.append({
|
||||
"path": rel_path,
|
||||
"size": stat.st_size,
|
||||
"hash": f"sha256:{sha256_hash.hexdigest()}"
|
||||
})
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
rel_path = str(file_path.relative_to(BUILDS_DIR))
|
||||
|
||||
# Only include runtime files needed for launcher updates
|
||||
# Skip: JRE, JavaFX, old ZIPs, extracted versions, docs, root JARs
|
||||
if rel_path.startswith(("jre21/", "lib/", "lib-javafx/", "versions/", "libs/")):
|
||||
continue
|
||||
if rel_path.endswith(".zip") or rel_path in ("README.txt", "build.version"):
|
||||
continue
|
||||
if file_path.name in ("meta.json",):
|
||||
continue
|
||||
# Skip root-level JARs — only bin/*.jar is needed at runtime
|
||||
if rel_path in ("zernmclauncher.jar", "zernmc-bootstrap.jar"):
|
||||
continue
|
||||
stat = file_path.stat()
|
||||
|
||||
# Calculate hash
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
sha256_hash.update(chunk)
|
||||
|
||||
files.append({
|
||||
"path": rel_path,
|
||||
"size": stat.st_size,
|
||||
"hash": f"sha256:{sha256_hash.hexdigest()}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to generate launcher meta: {e}")
|
||||
return
|
||||
@@ -1905,6 +1912,37 @@ async def get_news(news_id: str):
|
||||
}
|
||||
|
||||
|
||||
# ====================== WHITELIST MODS ======================
|
||||
|
||||
WHITELIST_MODS_FILE = WHITELIST_DIR / "mods.txt"
|
||||
|
||||
|
||||
@app.get("/whitelist/mods")
|
||||
async def list_whitelist_mods(current_user: dict = Depends(get_current_user)):
|
||||
"""Return whitelist mods list as name :: size :: hash lines"""
|
||||
if not WHITELIST_MODS_FILE.exists():
|
||||
return Response(content="", media_type="text/plain")
|
||||
|
||||
content = WHITELIST_MODS_FILE.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
return Response(content="", media_type="text/plain")
|
||||
|
||||
return Response(content=content, media_type="text/plain")
|
||||
|
||||
|
||||
@app.get("/whitelist/mods/{mod_hash}/{filename:path}")
|
||||
async def download_whitelist_mod(mod_hash: str, filename: str, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
"""Download whitelist mod file by hash"""
|
||||
if ".." in mod_hash or ".." in filename:
|
||||
raise HTTPException(400, "Invalid path")
|
||||
|
||||
mod_path = WHITELIST_DIR / mod_hash
|
||||
if not mod_path.exists() or not mod_path.is_file():
|
||||
raise HTTPException(404, "Mod not found")
|
||||
|
||||
return await send_file_async(mod_path, request, cache=True)
|
||||
|
||||
|
||||
# ====================== ПРОКСИ ЭНДПОИНТЫ ======================
|
||||
# Эти эндпоинты позволяют клиентам с сетевыми проблемами
|
||||
# скачивать файлы через сервер Zern
|
||||
|
||||
Reference in New Issue
Block a user