DevBlog №4 | массовый фикс JFX и фиксы связанные с самим лаунчером, добавление админ меню

This commit is contained in:
SashegDev
2026-07-01 19:36:52 +00:00
parent e49e630afe
commit 0d61ad1107
12 changed files with 1915 additions and 99 deletions
+331 -2
View File
@@ -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)):
"""Информация о текущем пользователе с правами"""