JCEF migration: add jcefmaven dep, make JavaFX optional, create LogBridge, wire JCEFLauncher entry point

This commit is contained in:
SashegDev
2026-07-02 11:49:09 +00:00
parent 0d61ad1107
commit 8eabe232ff
17 changed files with 3017 additions and 133 deletions
+216 -7
View File
@@ -43,6 +43,32 @@ class BanUserRequest(BaseModel):
days: int = Field(..., ge=1, le=365)
reason: str
class CreatePassCodeRequest(BaseModel):
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class UpdatePassRequest(BaseModel):
code: str
expires_days: Optional[int] = None
max_uses: int = Field(1, ge=1, le=10)
is_active: bool = True
class DeletePassRequest(BaseModel):
code: str
class CreatePassRequest(BaseModel):
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class UpdatePassRequest(BaseModel):
code: str
expires_days: Optional[int] = None
max_uses: int = Field(1, ge=1, le=10)
is_active: bool = True
class DeletePassRequest(BaseModel):
code: str
# ====================== ЭНДПОИНТЫ ======================
@router.get("/users")
@@ -532,11 +558,17 @@ async def get_admin_stats(
recent_registrations = conn.execute("""
SELECT COUNT(*) as count FROM users WHERE created_at > ?
""", (week_ago,)).fetchone()["count"]
# Онлайн пользователи
online_now = conn.execute("""
SELECT COUNT(*) as count FROM user_status WHERE is_online = 1
""").fetchone()["count"]
return {
"total_users": total_users,
"active_passes": active_passes,
"banned_users": banned_users,
"online_now": online_now,
"recent_registrations_7d": recent_registrations,
"roles_distribution": [
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
@@ -550,6 +582,85 @@ async def get_admin_stats(
}
# ====================== PASS CRUD ======================
@router.post("/passes/create")
async def admin_create_pass(
body: CreatePassCodeRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Create a new pass code"""
ip = request.client.host if request.client else "unknown"
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
with get_db() as conn:
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_at)
VALUES (?, ?, ?, ?, 1, 0, ?)
""", (pass_code, 'admin', expires_at, body.max_uses, now))
conn.commit()
log_audit(current_user["id"], "pass_create",
f"Created pass {pass_code[:8]}... (expires: {body.expires_days}d, max_uses: {body.max_uses})", ip)
return {"success": True, "code": pass_code, "expires_at": expires_at, "max_uses": body.max_uses}
@router.put("/passes/update")
async def admin_update_pass(
body: UpdatePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Update an existing pass"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
expires_at = time.time() + (body.expires_days * 86400) if body.expires_days else None
conn.execute("""
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
WHERE code = ?
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
conn.commit()
log_audit(current_user["id"], "pass_update",
f"Updated pass {body.code[:8]}... (active: {body.is_active})", ip)
return {"success": True}
@router.post("/passes/delete")
async def admin_delete_pass(
body: DeletePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None,
):
"""Delete (deactivate) a pass"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute("SELECT code FROM passes WHERE code = ?", (body.code,)).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
conn.commit()
log_audit(current_user["id"], "pass_delete",
f"Deleted pass {body.code[:8]}...", ip)
return {"success": True}
# ====================== WHITELIST MODS ======================
WHITELIST_DIR = Path(__file__).parent / "whitelist"
@@ -656,15 +767,21 @@ async def remove_whitelist_mod(
@router.get("/clients")
async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODERATOR))):
"""List online clients"""
"""List online clients with 5-minute stale timeout"""
with get_db() as conn:
# Auto-clean stale online status (no heartbeat > 5 min)
stale_cutoff = time.time() - 300
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
conn.commit()
rows = conn.execute("""
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen, s.ip_address
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
(SELECT s.ip_address FROM user_sessions s
WHERE s.user_id = u.id AND s.is_active = 1
ORDER BY s.created_at DESC LIMIT 1) as 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
JOIN user_status us ON u.id = us.user_id
WHERE us.is_online = 1
GROUP BY u.id
ORDER BY us.last_seen DESC
""").fetchall()
clients = []
@@ -672,7 +789,7 @@ async def admin_list_clients(current_user: dict = Depends(require_role(ROLE_MODE
clients.append({
"id": row["id"],
"username": row["username"],
"online": bool(row["is_online"]),
"online": True,
"current_pack": row["current_pack"] or "",
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
"ip": row["ip_address"] or ""
@@ -811,6 +928,98 @@ async def admin_list_passes(current_user: dict = Depends(require_role(ROLE_MODER
return {"success": True, "passes": passes}
@router.post("/passes/create")
async def create_pass(
body: CreatePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
ip = request.client.host if request.client else "unknown"
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
with get_db() as conn:
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active, uses, activated_by, activated_at)
VALUES (?, 'admin', ?, ?, 1, 0, NULL, ?)
""", (pass_code, expires_at, body.max_uses, now))
conn.commit()
log_audit(
current_user["id"],
"pass_create",
f"Created pass code {pass_code} (expires_days: {body.expires_days}, max_uses: {body.max_uses})",
ip
)
return {"success": True, "code": pass_code}
@router.put("/passes/update")
async def update_pass(
body: UpdatePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute(
"SELECT code FROM passes WHERE code = ?", (body.code,)
).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
expires_at = None
if body.expires_days is not None:
expires_at = time.time() + (body.expires_days * 86400)
conn.execute("""
UPDATE passes SET expires_at = ?, max_uses = ?, is_active = ?
WHERE code = ?
""", (expires_at, body.max_uses, 1 if body.is_active else 0, body.code))
conn.commit()
log_audit(
current_user["id"],
"pass_update",
f"Updated pass {body.code} (expires_days: {body.expires_days}, max_uses: {body.max_uses}, is_active: {body.is_active})",
ip
)
return {"success": True}
@router.post("/passes/delete")
async def delete_pass(
body: DeletePassRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
existing = conn.execute(
"SELECT code FROM passes WHERE code = ?", (body.code,)
).fetchone()
if not existing:
raise HTTPException(404, "Pass not found")
conn.execute("UPDATE passes SET is_active = 0 WHERE code = ?", (body.code,))
conn.execute("DELETE FROM user_passes WHERE pass_code = ?", (body.code,))
conn.commit()
log_audit(
current_user["id"],
"pass_delete",
f"Deactivated pass {body.code}",
ip
)
return {"success": True}
@router.post("/whitelist/mods/{mod_hash}/delete")
async def admin_delete_whitelist_mod_adapter(
mod_hash: str,
+4
View File
@@ -542,6 +542,10 @@ async def logout(current_user: dict = Depends(get_current_user), request: Reques
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
(current_user["id"],)
)
conn.execute(
"UPDATE user_status SET is_online = 0 WHERE user_id = ?",
(current_user["id"],)
)
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
+23
View File
@@ -116,7 +116,12 @@ async def remove_friend(
@router.get("/friends/list")
async def list_friends(current_user: dict = Depends(get_current_user)):
friends = []
stale_cutoff = time.time() - 300
with get_db() as conn:
# Auto-clean stale online
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
conn.commit()
rows = conn.execute("""
SELECT u.id, u.username, u.role,
COALESCE(us.is_online, 0) as online,
@@ -159,6 +164,17 @@ async def list_friend_requests(current_user: dict = Depends(get_current_user)):
})
return {"requests": requests}
def get_friends_ids(user_id: int) -> list[int]:
"""Get list of friend user_ids for a given user"""
with get_db() as conn:
rows = conn.execute("""
SELECT CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END as friend_id
FROM friendships f
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
""", (user_id, user_id, user_id)).fetchall()
return [r["friend_id"] for r in rows]
@router.post("/friends/status")
async def update_status(
req: StatusUpdateRequest,
@@ -173,4 +189,11 @@ async def update_status(
current_pack = COALESCE(excluded.current_pack, user_status.current_pack),
last_seen = CURRENT_TIMESTAMP
""", (current_user["id"], int(req.online), req.current_pack or ""))
# Broadcast status change to friends via WebSocket
try:
from ws_manager import manager
friend_ids = get_friends_ids(current_user["id"])
await manager.broadcast_status_change(current_user["id"], friend_ids)
except Exception:
pass
return {"status": "ok"}
+154 -2
View File
@@ -11,7 +11,7 @@ import httpx
import json
import structlog
from cachetools import TTLCache
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect, Query
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
@@ -28,8 +28,9 @@ 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
from friends import router as friends_router, init_friends_db
from friends import router as friends_router, init_friends_db, get_friends_ids
from playtime import router as playtime_router, init_playtime_db
from ws_manager import manager
import asyncio
import hashlib
@@ -222,8 +223,14 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_sync())
# Start WebSocket admin clients broadcaster
broadcaster = asyncio.create_task(admin_clients_broadcaster())
yield
# Cancel WebSocket broadcaster
broadcaster.cancel()
# Cleanup proxy client
if proxy_client:
await proxy_client.aclose()
@@ -925,6 +932,151 @@ app.include_router(friends_router)
app.include_router(playtime_router)
# ====================== WEBSOCKET ======================
async def admin_clients_broadcaster():
"""Periodically push client list to connected admins via WebSocket"""
while True:
await asyncio.sleep(5)
try:
from auth import get_db
from roles import ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
admin_ids = [uid for uid in list(manager._connections.keys())]
if not admin_ids:
continue
# Check which are actually admins
with get_db() as conn:
admin_rows = conn.execute(
"SELECT id FROM users WHERE id IN ({}) AND role >= ?".format(
",".join("?" for _ in admin_ids)),
admin_ids + [ROLE_MODERATOR]
).fetchall()
real_admins = [r["id"] for r in admin_rows]
if not real_admins:
continue
stale_cutoff = time.time() - 300
with get_db() as conn:
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (stale_cutoff,))
conn.commit()
rows = conn.execute("""
SELECT u.id, u.username, us.is_online, us.current_pack, us.last_seen,
(SELECT s.ip_address FROM user_sessions s
WHERE s.user_id = u.id AND s.is_active = 1
ORDER BY s.created_at DESC LIMIT 1) as ip_address
FROM users u
JOIN user_status us ON u.id = us.user_id
WHERE us.is_online = 1
ORDER BY us.last_seen DESC
""").fetchall()
clients = []
for row in rows:
clients.append({
"id": row["id"], "username": row["username"],
"online": True, "current_pack": row["current_pack"] or "",
"last_seen": str(row["last_seen"]) if row["last_seen"] else None,
"ip": row["ip_address"] or ""
})
await manager.broadcast_admin_clients(clients, real_admins)
except Exception as exc:
logger.debug("admin_clients_broadcaster error", error=str(exc))
@app.websocket("/ws")
async def websocket_endpoint(
ws: WebSocket,
token: str = Query(...),
):
try:
payload = verify_jwt(token)
if payload is None:
await ws.close(code=4001, reason="Invalid token")
return
user_id = payload.get("user_id")
if user_id is None:
await ws.close(code=4001, reason="Invalid token payload")
return
except Exception:
await ws.close(code=4001, reason="Invalid token")
return
await manager.connect(user_id, ws)
# Send initial friends list
try:
with get_db() as conn:
cutoff = time.time() - 300
conn.execute("UPDATE user_status SET is_online = 0 WHERE last_seen < ?", (cutoff,))
conn.commit()
rows = conn.execute("""
SELECT u.id, u.username, u.role,
COALESCE(us.is_online, 0) as online,
COALESCE(us.current_pack, '') as current_pack,
us.last_seen
FROM friendships f
JOIN users u ON (CASE WHEN f.requester_id = ? THEN f.target_id ELSE f.requester_id END) = u.id
LEFT JOIN user_status us ON u.id = us.user_id
WHERE (f.requester_id = ? OR f.target_id = ?) AND f.status = 'accepted'
""", (user_id, user_id, user_id))
friends = []
for row in rows:
friends.append({
"id": row["id"], "username": row["username"],
"role": row["role"], "online": bool(row["online"]),
"current_pack": row["current_pack"],
"last_seen": str(row["last_seen"]) if row["last_seen"] else None
})
await manager.send_json(user_id, {"type": "friends_update", "friends": friends})
# Send initial friend requests
with get_db() as conn:
req_rows = conn.execute("""
SELECT u.id, u.username, u.role, f.created_at
FROM friendships f
JOIN users u ON f.requester_id = u.id
WHERE f.target_id = ? AND f.status = 'pending'
""", (user_id,))
requests = []
for row in req_rows:
requests.append({
"id": row["id"], "username": row["username"],
"role": row["role"], "created_at": str(row["created_at"]) if row["created_at"] else None
})
await manager.send_json(user_id, {"type": "requests_update", "requests": requests})
except Exception as exc:
logger.warning("WS initial data error", error=str(exc))
try:
while True:
data = await ws.receive_text()
try:
msg = json.loads(data)
msg_type = msg.get("type")
if msg_type == "ping":
await ws.send_text(json.dumps({"type": "pong"}))
elif msg_type == "status":
current_pack = msg.get("current_pack", "")
with get_db() as conn:
conn.execute("""
INSERT INTO user_status (user_id, is_online, current_pack, last_seen)
VALUES (?, 1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET
is_online = 1,
current_pack = COALESCE(?, user_status.current_pack),
last_seen = CURRENT_TIMESTAMP
""", (user_id, current_pack, current_pack))
# Notify friends
friend_ids = get_friends_ids(user_id)
await manager.broadcast_status_change(user_id, friend_ids)
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
pass
except Exception as exc:
logger.warning("WS error", user_id=user_id, error=str(exc))
finally:
manager.disconnect(user_id, ws)
# Monkey patch to catch invalid HTTP requests
original_data_received = HttpToolsProtocol.data_received
+68
View File
@@ -0,0 +1,68 @@
import asyncio
import json
import structlog
import time
from collections import defaultdict
from typing import Optional
from fastapi import WebSocket
logger = structlog.get_logger(__name__)
class ConnectionManager:
def __init__(self):
# user_id -> list of WebSocket connections
self._connections: dict[int, list[WebSocket]] = defaultdict(list)
# user_id -> set of friend user_ids (cached for broadcast)
self._friend_cache: dict[int, set[int]] = {}
self._cleanup_lock = asyncio.Lock()
async def connect(self, user_id: int, ws: WebSocket):
await ws.accept()
self._connections[user_id].append(ws)
logger.info("WS connected", user_id=user_id, total=len(self._connections))
def disconnect(self, user_id: int, ws: WebSocket):
conns = self._connections.get(user_id, [])
if ws in conns:
conns.remove(ws)
if not self._connections.get(user_id):
self._connections.pop(user_id, None)
logger.info("WS disconnected", user_id=user_id, total=len(self._connections))
async def send_json(self, user_id: int, data: dict):
conns = self._connections.get(user_id, [])
if not conns:
return
msg = json.dumps(data)
dead = []
for ws in conns:
try:
await ws.send_text(msg)
except Exception:
dead.append(ws)
for ws in dead:
self.disconnect(user_id, ws)
async def broadcast_friends_update(self, user_id: int, friends_data: list[dict]):
"""Broadcast friend list update to a specific user"""
await self.send_json(user_id, {
"type": "friends_update",
"friends": friends_data
})
async def broadcast_status_change(self, user_id: int, friends: list[int]):
"""Notify all friends that this user's status changed"""
data = {"type": "friend_status", "user_id": user_id}
for fid in friends:
await self.send_json(fid, data)
async def broadcast_admin_clients(self, clients_data: list[dict], admin_ids: list[int]):
"""Broadcast client list to all connected admins"""
data = {"type": "clients_update", "clients": clients_data}
for aid in admin_ids:
await self.send_json(aid, data)
manager = ConnectionManager()