Merge branch 'main' into alpha

This commit is contained in:
Sashegdev
2026-04-22 15:26:39 +03:00
13 changed files with 1597 additions and 482 deletions
+590
View File
@@ -0,0 +1,590 @@
# admin_router.py
from fastapi import APIRouter, HTTPException, Depends, Request, status
from pydantic import BaseModel, Field
from typing import Optional, List
import structlog
import time
import secrets
from datetime import datetime
from auth import get_db, require_role, log_audit, get_current_user
from roles import (
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/admin", tags=["admin"])
# ====================== МОДЕЛИ ======================
class UpdateRoleRequest(BaseModel):
user_id: int
role: int = Field(..., ge=0, le=4)
class PassRequest(BaseModel):
username: str
reason: Optional[str] = None
class PassDecision(BaseModel):
request_id: int
approved: bool
reason: Optional[str] = None
class CreatePassDirectRequest(BaseModel):
username: str
expires_days: Optional[int] = Field(None, ge=1, le=365)
max_uses: int = Field(1, ge=1, le=10)
class BanUserRequest(BaseModel):
user_id: int
days: int = Field(..., ge=1, le=365)
reason: str
# ====================== ЭНДПОИНТЫ ======================
@router.get("/users")
async def list_users(
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
search: Optional[str] = None
):
"""Список пользователей (модераторы видят всех, но без sensitive данных)"""
with get_db() as conn:
query = "SELECT id, username, uuid, role, created_at, last_login, is_active"
params = []
if current_user["role"] < ROLE_ELDER:
# Модераторы не видят забаненных
query += " FROM users WHERE is_active = 1"
else:
query += " FROM users"
if search:
query += " AND (username LIKE ? OR email LIKE ?)"
params.extend([f"%{search}%", f"%{search}%"])
query += " ORDER BY role DESC, username"
rows = conn.execute(query, params).fetchall()
users = []
for row in rows:
user_data = {
"id": row["id"],
"username": row["username"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
}
# Elder и Creator видят больше информации
if current_user["role"] >= ROLE_ELDER:
user_data["is_active"] = row["is_active"]
# Получаем информацию о проходке
pass_info = conn.execute("""
SELECT code, expires_at, activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
LIMIT 1
""", (row["id"], time.time())).fetchone()
if pass_info:
user_data["has_pass"] = True
user_data["pass_expires"] = pass_info["expires_at"]
users.append(user_data)
return {"users": users, "total": len(users)}
@router.get("/users/{user_id}")
async def get_user_detail(
user_id: int,
current_user: dict = Depends(require_role(ROLE_MODERATOR))
):
"""Детальная информация о пользователе"""
with get_db() as conn:
row = conn.execute("""
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
FROM users WHERE id = ?
""", (user_id,)).fetchone()
if not row:
raise HTTPException(404, "Пользователь не найден")
# Модераторы не видят email обычных пользователей
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
email = None
else:
email = row["email"]
# Получаем активную проходку
pass_info = None
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
pass_row = conn.execute("""
SELECT p.code, p.expires_at, up.activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
LIMIT 1
""", (user_id, time.time())).fetchone()
if pass_row:
pass_info = {
"code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"],
"expires_at": pass_row["expires_at"],
"activated_at": pass_row["activated_at"]
}
# Логи действий (только для Elder+)
actions = []
if current_user["role"] >= ROLE_ELDER:
action_rows = conn.execute("""
SELECT action, details, timestamp FROM audit_log
WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20
""", (user_id,)).fetchall()
actions = [dict(row) for row in action_rows]
return {
"id": row["id"],
"username": row["username"],
"email": email,
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"is_active": row["is_active"],
"banned_until": row["banned_until"],
"has_pass": pass_info is not None,
"pass_info": pass_info,
"recent_actions": actions if current_user["role"] >= ROLE_ELDER else None
}
@router.put("/users/{user_id}/role")
async def update_user_role(
user_id: int,
body: UpdateRoleRequest,
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:
target = conn.execute(
"SELECT id, username, role FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
# Проверки прав
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Нельзя изменить роль создателя")
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем")
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})")
# Elder не может создавать других Elder (только Creator)
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Только создатель может назначать Elder Moderator")
# Проверяем, нужно ли выдать/отозвать проходку
old_role = target["role"]
new_role = body.role
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(new_role, user_id)
)
# Управление проходками при изменении роли
now = time.time()
if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER:
# Выдаем проходку если её нет
existing = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (user_id, now)).fetchone()
if not existing:
# Создаем автоматическую проходку
pass_code = f"AUTO_{secrets.token_hex(8).upper()}"
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
VALUES (?, ?, NULL, 1, 1)
""", (pass_code, target["username"]))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
VALUES (?, ?, ?)
""", (user_id, pass_code, now))
logger.info("Auto-pass issued", user=target["username"], role=new_role)
elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER:
# Отзываем проходку
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (user_id,))
logger.info("Auto-pass revoked", user=target["username"])
conn.commit()
log_audit(
current_user["id"],
"role_change",
f"Changed role of {target['username']} from {old_role} to {new_role}",
ip
)
logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role)
return {
"success": True,
"user_id": user_id,
"username": target["username"],
"old_role": old_role,
"old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"),
"new_role": new_role,
"new_role_name": ROLE_NAMES.get(new_role, "Неизвестно")
}
@router.post("/pass/grant")
async def grant_pass(
body: CreatePassDirectRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Выдача проходки пользователю (Elder+ могут выдавать)"""
ip = request.client.host if request.client else "unknown"
# Проверяем право на прямую выдачу
if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS):
raise HTTPException(403, "Недостаточно прав для выдачи проходки")
with get_db() as conn:
target = conn.execute(
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
(body.username,)
).fetchone()
if not target:
raise HTTPException(404, f"Пользователь {body.username} не найден")
# Проверяем, есть ли уже активная проходка
existing = conn.execute("""
SELECT p.code FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (target["id"], time.time())).fetchone()
if existing:
raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка")
# Создаем проходку
pass_code = secrets.token_hex(12).upper()
now = time.time()
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
conn.execute("""
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
VALUES (?, ?, ?, ?, 1)
""", (pass_code, target["username"], expires_at, body.max_uses))
conn.execute("""
INSERT INTO user_passes (user_id, pass_code, activated_at)
VALUES (?, ?, ?)
""", (target["id"], pass_code, now))
# Обновляем роль если нужно
if target["role"] < ROLE_PASS_HOLDER:
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(ROLE_PASS_HOLDER, target["id"])
)
conn.commit()
log_audit(
current_user["id"],
"grant_pass",
f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})",
ip
)
logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code)
return {
"success": True,
"pass_code": pass_code,
"username": target["username"],
"expires_at": expires_at,
"expires_days": body.expires_days,
"max_uses": body.max_uses
}
@router.delete("/pass/revoke/{username}")
async def revoke_pass(
username: str,
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:
target = conn.execute(
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
(username,)
).fetchone()
if not target:
raise HTTPException(404, f"Пользователь {username} не найден")
# Отзываем проходку
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (target["id"],))
# Понижаем роль если она была только из-за проходки
if target["role"] == ROLE_PASS_HOLDER:
conn.execute(
"UPDATE users SET role = ? WHERE id = ?",
(ROLE_USER, target["id"])
)
conn.commit()
log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip)
logger.info("Pass revoked", admin=current_user["username"], target=username)
return {"success": True, "message": f"Проходка {username} отозвана"}
@router.post("/user/ban")
async def ban_user(
body: BanUserRequest,
current_user: dict = Depends(require_role(ROLE_ELDER)),
request: Request = None
):
"""Бан пользователя (Elder+ могут банить)"""
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, "Пользователь не найден")
# Нельзя забанить создателя
if target["role"] == ROLE_CREATOR:
raise HTTPException(403, "Нельзя забанить создателя")
# Elder не может банить других Elder
if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
raise HTTPException(403, "Недостаточно прав для бана этого пользователя")
banned_until = time.time() + (body.days * 86400)
conn.execute(
"UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?",
(banned_until, target["id"])
)
# Отзываем проходку при бане
conn.execute("""
UPDATE passes SET is_active = 0
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
""", (target["id"],))
conn.commit()
log_audit(
current_user["id"],
"ban_user",
f"Banned {target['username']} for {body.days} days. Reason: {body.reason}",
ip
)
logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days)
return {
"success": True,
"username": target["username"],
"banned_until": banned_until,
"days": body.days
}
@router.post("/user/unban/{user_id}")
async def unban_user(
user_id: int,
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:
target = conn.execute(
"SELECT id, username FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not target:
raise HTTPException(404, "Пользователь не найден")
conn.execute(
"UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?",
(user_id,)
)
conn.commit()
log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip)
logger.info("User unbanned", admin=current_user["username"], target=target["username"])
return {"success": True, "username": target["username"]}
@router.get("/audit")
async def get_audit_log(
current_user: dict = Depends(require_role(ROLE_ELDER)),
limit: int = 50,
offset: int = 0,
user_id: Optional[int] = None
):
"""Просмотр логов аудита (только Elder+)"""
with get_db() as conn:
query = """
SELECT al.*, u.username
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.id
"""
params = []
if user_id:
query += " WHERE al.user_id = ?"
params.append(user_id)
query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
rows = conn.execute(query, params).fetchall()
total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"]
return {
"logs": [dict(row) for row in rows],
"total": total,
"limit": limit,
"offset": offset
}
@router.get("/stats")
async def get_admin_stats(
current_user: dict = Depends(require_role(ROLE_MODERATOR))
):
"""Статистика для админов"""
with get_db() as conn:
# Общая статистика
total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"]
# Статистика по ролям
role_stats = conn.execute("""
SELECT role, COUNT(*) as count
FROM users
GROUP BY role
ORDER BY role DESC
""").fetchall()
# Активные проходки
active_passes = conn.execute("""
SELECT COUNT(*) as count FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE p.expires_at IS NULL OR p.expires_at > ?
""", (time.time(),)).fetchone()["count"]
# Забаненные пользователи
banned_users = conn.execute("""
SELECT COUNT(*) as count FROM users
WHERE is_active = 0 AND banned_until > ?
""", (time.time(),)).fetchone()["count"]
# Недавние регистрации (последние 7 дней)
week_ago = time.time() - (7 * 86400)
recent_registrations = conn.execute("""
SELECT COUNT(*) as count FROM users WHERE created_at > ?
""", (week_ago,)).fetchone()["count"]
return {
"total_users": total_users,
"active_passes": active_passes,
"banned_users": banned_users,
"recent_registrations_7d": recent_registrations,
"roles_distribution": [
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
for r in role_stats
],
"my_info": {
"role": current_user["role"],
"role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"),
"username": current_user["username"]
}
}
@router.get("/me")
async def get_my_info(current_user: dict = Depends(get_current_user)):
"""Информация о текущем пользователе с правами"""
with get_db() as conn:
row = conn.execute("""
SELECT id, username, email, uuid, role, created_at, last_login
FROM users WHERE id = ?
""", (current_user["id"],)).fetchone()
# Проверяем наличие активной проходки
has_pass = False
if row["role"] >= ROLE_PASS_HOLDER:
pass_row = conn.execute("""
SELECT 1 FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
""", (current_user["id"], time.time())).fetchone()
has_pass = pass_row is not None
permissions = list(ROLE_PERMISSIONS.get(row["role"], set()))
return {
"id": row["id"],
"username": row["username"],
"email": row["email"],
"uuid": row["uuid"],
"role": row["role"],
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
"created_at": row["created_at"],
"last_login": row["last_login"],
"has_pass": has_pass,
"permissions": permissions
}
+461 -267
View File
@@ -8,25 +8,33 @@ import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from contextlib import contextmanager
import structlog
from fastapi import APIRouter, HTTPException, Request, Depends
from fastapi import APIRouter, HTTPException, Request, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
import re
logger = structlog.get_logger(__name__)
# ====================== КОНФИГ ======================
AUTH_DB = Path("data/auth.db")
AUTH_DB.parent.mkdir(exist_ok=True)
SECRET_KEY = Path("data/.secret_key")
RATE_LIMIT_DB = Path("data/rate_limit.db")
ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа
REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней
# Токены
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
REFRESH_TOKEN_EXPIRE_DAYS = 30
# Лимиты
MAX_LOGIN_ATTEMPTS = 5
LOGIN_BLOCK_MINUTES = 15
# ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
def _get_secret() -> bytes:
"""Безопасное получение/создание секретного ключа"""
if SECRET_KEY.exists():
return SECRET_KEY.read_bytes()
key = secrets.token_bytes(64)
@@ -36,31 +44,46 @@ def _get_secret() -> bytes:
_SECRET = _get_secret()
def create_jwt(payload: dict) -> str:
# ====================== JWT ФУНКЦИИ ======================
def create_jwt(payload: dict, expires_in: int = None) -> str:
"""Создание JWT токена"""
if expires_in is None:
expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60
payload = payload.copy()
payload["exp"] = time.time() + expires_in
payload["iat"] = time.time()
payload["jti"] = secrets.token_hex(16)
header = base64.urlsafe_b64encode(
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
).rstrip(b'=').decode()
body = base64.urlsafe_b64encode(
json.dumps(payload).encode()
).rstrip(b'=').decode()
msg = f"{header}.{body}".encode()
sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
def verify_jwt(token: str) -> Optional[dict]:
"""Верификация JWT токена"""
try:
parts = token.split(".")
if len(parts) != 3:
return None
header, body, sig = parts
msg = f"{header}.{body}".encode()
expected = hmac.new(_SECRET, msg, hashlib.sha256).digest()
# Исправлено: правильный паддинг для base64url
header, body, sig = parts
sig_padded = sig + '=' * (4 - len(sig) % 4)
expected_sig = base64.urlsafe_b64decode(sig_padded)
msg = f"{header}.{body}".encode()
if not hmac.compare_digest(
base64.urlsafe_b64decode(sig_padded),
expected
hmac.new(_SECRET, msg, hashlib.sha256).digest(),
expected_sig
):
return None
@@ -69,75 +92,154 @@ def verify_jwt(token: str) -> Optional[dict]:
if payload.get("exp", 0) < time.time():
return None
return payload
except Exception:
return None
# ====================== БАЗА ДАННЫХ ======================
@contextmanager
def get_db():
conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False)
"""Контекстный менеджер для БД"""
conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False, timeout=10)
conn.row_factory = sqlite3.Row
return conn
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
conn = get_db()
def init_rate_limit_db():
"""Инициализация БД для rate limiting"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
uuid TEXT UNIQUE NOT NULL,
created_at REAL NOT NULL,
last_login REAL
);
CREATE TABLE IF NOT EXISTS passes (
code TEXT PRIMARY KEY,
owner TEXT,
is_active BOOLEAN DEFAULT 1,
activated_by INTEGER REFERENCES users(id),
activated_at REAL,
expires_at REAL,
max_uses INTEGER DEFAULT 1,
uses INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS user_passes (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
pass_code TEXT REFERENCES passes(code),
activated_at REAL NOT NULL,
PRIMARY KEY (user_id, pass_code)
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
expires_at REAL NOT NULL
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
attempts INTEGER DEFAULT 1,
first_attempt REAL NOT NULL,
last_attempt REAL NOT NULL,
blocked_until REAL
);
""")
conn.commit()
conn.close()
def init_db():
"""Инициализация основной БД"""
with get_db() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
uuid TEXT UNIQUE NOT NULL,
role INTEGER DEFAULT 0,
created_at REAL NOT NULL,
last_login REAL,
is_active BOOLEAN DEFAULT 1,
banned_until REAL
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
jti TEXT NOT NULL,
expires_at REAL NOT NULL,
revoked BOOLEAN DEFAULT 0,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token TEXT UNIQUE NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at REAL NOT NULL,
expires_at REAL NOT NULL,
is_active BOOLEAN DEFAULT 1
);
CREATE TABLE IF NOT EXISTS passes (
code TEXT PRIMARY KEY,
owner TEXT,
is_active BOOLEAN DEFAULT 1,
activated_by INTEGER REFERENCES users(id),
activated_at REAL,
expires_at REAL,
max_uses INTEGER DEFAULT 1,
uses INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS user_passes (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
pass_code TEXT REFERENCES passes(code),
activated_at REAL NOT NULL,
PRIMARY KEY (user_id, pass_code)
);
CREATE TABLE IF NOT EXISTS pass_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requester_id INTEGER NOT NULL REFERENCES users(id),
target_username TEXT NOT NULL,
reason TEXT,
status TEXT DEFAULT 'pending',
decision_reason TEXT,
created_at REAL NOT NULL,
reviewed_by INTEGER REFERENCES users(id),
reviewed_at REAL
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id),
action TEXT NOT NULL,
details TEXT,
ip_address TEXT,
timestamp REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
""")
# Добавляем колонку role если её нет
cursor = conn.execute("PRAGMA table_info(users)")
columns = [col[1] for col in cursor.fetchall()]
if "role" not in columns:
conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0")
logger.info("Added role column to users table")
init_rate_limit_db()
logger.info("Auth database initialized")
# ====================== ХЕЛПЕРЫ ======================
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
"""Хэширование пароля"""
salt = secrets.token_hex(32)
hash_obj = hashlib.pbkdf2_hmac(
'sha256',
password.encode(),
salt.encode(),
password.encode('utf-8'),
salt.encode('utf-8'),
300000
)
return f"{salt}${hash_obj.hex()}"
def verify_password(password: str, stored: str) -> bool:
"""Верификация пароля"""
try:
salt, stored_hash = stored.split('$')
hash_obj = hashlib.pbkdf2_hmac(
'sha256',
password.encode(),
salt.encode(),
password.encode('utf-8'),
salt.encode('utf-8'),
300000
)
return hmac.compare_digest(hash_obj.hex(), stored_hash)
@@ -145,280 +247,372 @@ def verify_password(password: str, stored: str) -> bool:
return False
def generate_uuid() -> str:
"""Генерация UUID"""
return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]:
"""Проверка rate limiting"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
now = time.time()
try:
row = conn.execute(
"SELECT attempts, blocked_until FROM login_attempts WHERE ip = ?",
(ip,)
).fetchone()
if row:
blocked_until = row[1]
if blocked_until and blocked_until > now:
return False, int(blocked_until - now)
if row[0] >= MAX_LOGIN_ATTEMPTS:
blocked_until = now + (LOGIN_BLOCK_MINUTES * 60)
conn.execute(
"UPDATE login_attempts SET blocked_until = ? WHERE ip = ?",
(blocked_until, ip)
)
conn.commit()
return False, LOGIN_BLOCK_MINUTES * 60
return True, None
finally:
conn.close()
def record_login_attempt(ip: str, success: bool):
"""Запись попытки входа"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
now = time.time()
try:
if success:
conn.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,))
else:
conn.execute("""
INSERT INTO login_attempts (ip, attempts, first_attempt, last_attempt)
VALUES (?, 1, ?, ?)
ON CONFLICT(ip) DO UPDATE SET
attempts = attempts + 1,
last_attempt = ?
""", (ip, now, now, now))
conn.commit()
finally:
conn.close()
def log_audit(user_id: int, action: str, details: str, ip_address: str):
"""Логирование действий"""
with get_db() as conn:
conn.execute(
"INSERT INTO audit_log (user_id, action, details, ip_address, timestamp) VALUES (?, ?, ?, ?, ?)",
(user_id, action, details, ip_address, time.time())
)
# ====================== МОДЕЛИ ======================
class LoginRequest(BaseModel):
username: str
password: str
username: str = Field(..., min_length=3, max_length=32)
password: str = Field(..., min_length=6, max_length=128)
@field_validator('username')
def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
return v.lower()
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$")
username: str = Field(..., min_length=3, max_length=32)
password: str = Field(..., min_length=6, max_length=128)
@field_validator('username')
def validate_username(cls, v):
if not re.match(r'^[a-zA-Z0-9_]+$', v):
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
return v.lower()
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
expires_in: int
token_type: str = "bearer"
username: str
uuid: str
role: int
role_name: str
# ====================== ROUTER ======================
# ====================== DEPENDENCIES ======================
router = APIRouter(prefix="/auth", tags=["auth"])
bearer = HTTPBearer(auto_error=False)
def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse:
now = time.time()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer),
request: Request = None
) -> dict:
"""Получение текущего пользователя"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Не авторизован",
headers={"WWW-Authenticate": "Bearer"},
)
payload = verify_jwt(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен"
)
with get_db() as conn:
user = conn.execute(
"SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?",
(payload["sub"],)
).fetchone()
if not user:
raise HTTPException(401, "Пользователь не найден")
if not user["is_active"]:
raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time():
raise HTTPException(403, "Аккаунт забанен")
return {
"id": user["id"],
"username": user["username"],
"uuid": user["uuid"],
"role": user["role"]
}
access_token = create_jwt({
"sub": user_id,
"username": username,
"uuid": uuid,
"type": "access",
"exp": now + ACCESS_TOKEN_EXPIRE_SECONDS
})
refresh_token = create_jwt({
"sub": user_id,
"type": "refresh",
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS
})
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,))
conn.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS)
)
conn.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=ACCESS_TOKEN_EXPIRE_SECONDS,
username=username,
uuid=uuid
)
def require_role(min_role: int):
"""Декоратор для проверки роли"""
async def dependency(current_user: dict = Depends(get_current_user)):
if current_user["role"] < min_role:
from roles import ROLE_NAMES
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Требуется роль {ROLE_NAMES.get(min_role, 'неизвестная')}"
)
return current_user
return dependency
# ====================== ЭНДПОИНТЫ ======================
@router.post("/register", response_model=TokenResponse)
async def register(body: RegisterRequest, request: Request):
conn = get_db()
try:
"""Регистрация нового пользователя"""
ip = request.client.host if request.client else "unknown"
allowed, wait = check_rate_limit(ip)
if not allowed:
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn:
existing = conn.execute(
"SELECT 1 FROM users WHERE username = ? COLLATE NOCASE",
"SELECT username FROM users WHERE username = ?",
(body.username,)
).fetchone()
if existing:
raise HTTPException(status_code=409, detail="Имя пользователя уже занято")
raise HTTPException(409, "Пользователь с таким именем уже существует")
uuid = generate_uuid()
pw_hash = hash_password(body.password)
now = time.time()
cursor = conn.execute(
"INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)",
(body.username, pw_hash, uuid, now)
"""INSERT INTO users (username, password_hash, uuid, created_at, role)
VALUES (?, ?, ?, ?, ?)""",
(body.username, pw_hash, uuid, now, 0) # role 0 = обычный пользователь
)
conn.commit()
user_id = cursor.lastrowid
tokens = _issue_tokens(conn, user_id, body.username, uuid)
logger.info("User registered", username=body.username, user_id=user_id)
return tokens
except HTTPException:
raise
except Exception as e:
logger.error("Register error", exc_info=True)
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
finally:
conn.close()
# Создаем сессию
session_token = secrets.token_urlsafe(32)
conn.execute(
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
)
# Токены
access_token = create_jwt({
"sub": user_id,
"username": body.username,
"uuid": uuid,
"role": 0,
"type": "access",
"jti": session_token
})
refresh_token = create_jwt({
"sub": user_id,
"type": "refresh",
"jti": secrets.token_hex(16)
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
conn.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
(user_id, refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
)
log_audit(user_id, "register", f"User registered from {ip}", ip)
logger.info("User registered", username=body.username, user_id=user_id, ip=ip)
from roles import ROLE_NAMES
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
username=body.username,
uuid=uuid,
role=0,
role_name=ROLE_NAMES[0]
)
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, request: Request):
conn = get_db()
try:
row = conn.execute(
"SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE",
"""Вход в систему"""
ip = request.client.host if request.client else "unknown"
allowed, wait = check_rate_limit(ip)
if not allowed:
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
with get_db() as conn:
user = conn.execute(
"SELECT id, username, uuid, password_hash, role, is_active, banned_until FROM users WHERE username = ?",
(body.username,)
).fetchone()
if not row or not verify_password(body.password, row["password_hash"]):
if not user or not verify_password(body.password, user["password_hash"]):
record_login_attempt(ip, False)
log_audit(0, "login_failed", f"Failed login for {body.username} from {ip}", ip)
raise HTTPException(401, "Неверное имя пользователя или пароль")
if not user["is_active"]:
raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time():
raise HTTPException(403, "Аккаунт забанен")
record_login_attempt(ip, True)
now = time.time()
conn.execute(
"UPDATE users SET last_login = ? WHERE id = ?",
(time.time(), row["id"])
(now, user["id"])
)
# Создаем сессию
session_token = secrets.token_urlsafe(32)
conn.execute(
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
(user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
)
access_token = create_jwt({
"sub": user["id"],
"username": user["username"],
"uuid": user["uuid"],
"role": user["role"],
"type": "access",
"jti": session_token
})
refresh_token = create_jwt({
"sub": user["id"],
"type": "refresh",
"jti": secrets.token_hex(16)
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
conn.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
(user["id"], refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
)
log_audit(user["id"], "login", f"User logged in from {ip}", ip)
logger.info("User logged in", username=user["username"], user_id=user["id"], ip=ip)
from roles import ROLE_NAMES
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
username=user["username"],
uuid=user["uuid"],
role=user["role"],
role_name=ROLE_NAMES.get(user["role"], "Неизвестно")
)
conn.commit()
logger.info("User logged in", username=body.username, user_id=row["id"])
return _issue_tokens(conn, row["id"], row["username"], row["uuid"])
finally:
conn.close()
@router.post("/logout")
async def logout(current_user: dict = Depends(get_current_user), request: Request = None):
"""Выход из системы"""
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
conn.execute(
"UPDATE user_sessions SET is_active = 0 WHERE user_id = ?",
(current_user["id"],)
)
conn.execute(
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
(current_user["id"],)
)
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
logger.info("User logged out", user_id=current_user["id"], ip=ip)
return {"success": True}
@router.post("/refresh")
async def refresh(body: dict):
async def refresh(body: dict, request: Request):
"""Обновление access токена"""
refresh_token = body.get("refresh_token")
if not refresh_token:
raise HTTPException(400, "refresh_token обязателен")
payload = verify_jwt(refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(401, "Недействительный refresh token")
conn = get_db()
try:
ip = request.client.host if request.client else "unknown"
with get_db() as conn:
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
row = conn.execute(
"SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
token_row = conn.execute(
"SELECT user_id, revoked FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
(token_hash, time.time())
).fetchone()
if not row:
if not token_row or token_row["revoked"]:
raise HTTPException(401, "Refresh token истёк или недействителен")
user_row = conn.execute(
"SELECT id, username, uuid FROM users WHERE id = ?",
(row["user_id"],)
user = conn.execute(
"SELECT id, username, uuid, role FROM users WHERE id = ? AND is_active = 1",
(token_row["user_id"],)
).fetchone()
if not user_row:
raise HTTPException(401, "Пользователь не найден")
return _issue_tokens(conn, user_row["id"], user_row["username"], user_row["uuid"])
finally:
conn.close()
@router.post("/logout")
async def logout(body: dict):
refresh_token = body.get("refresh_token")
if refresh_token:
conn = get_db()
try:
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
conn.execute(
"DELETE FROM refresh_tokens WHERE token_hash = ?",
(token_hash,)
)
conn.commit()
finally:
conn.close()
return {"success": True}
# ====================== ПРОХОДКИ ======================
class ActivatePassRequest(BaseModel):
pass_code: str = Field(..., min_length=8, max_length=20)
@router.post("/pass/activate")
async def activate_pass_endpoint(
body: ActivatePassRequest,
credentials: HTTPAuthorizationCredentials = Depends(bearer)
):
if not credentials:
raise HTTPException(401, "Требуется авторизация")
payload = verify_jwt(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(401, "Недействительный токен")
user_id = payload["sub"]
username = payload["username"]
pass_code = body.pass_code.upper().strip()
conn = get_db()
try:
pass_row = conn.execute(
"SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?",
(pass_code,)
).fetchone()
if not pass_row:
raise HTTPException(404, "Проходка не найдена")
# Проверка срока
if pass_row["expires_at"] and pass_row["expires_at"] < time.time():
raise HTTPException(410, "Проходка истекла")
# Проверка лимита использований
if pass_row["uses"] >= pass_row["max_uses"]:
raise HTTPException(410, "Проходка уже использована")
# Проверка владельца
if pass_row["owner"] is not None:
if pass_row["owner"] != username:
raise HTTPException(409, "Проходка уже активирована другим пользователем")
# Уже активирована этим пользователем
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
if not user:
raise HTTPException(401, "Пользователь не найден или заблокирован")
now = time.time()
# Активация
session_token = secrets.token_urlsafe(32)
conn.execute(
"INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)",
(user_id, pass_code, now)
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
(user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
)
conn.execute(
"""UPDATE passes
SET uses = uses + 1,
owner = ?,
activated_by = ?,
activated_at = ?
WHERE code = ?""",
(username, user_id, now, pass_code)
)
conn.commit()
logger.info("Pass activated", user_id=user_id, username=username, pass_code=pass_code)
return {"success": True, "message": "Проходка успешно активирована!"}
except HTTPException:
raise
except Exception as e:
logger.error("Pass activation error", exc_info=True)
raise HTTPException(500, f"Ошибка сервера: {str(e)}")
finally:
conn.close()
@router.get("/pass/my")
async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)):
if not credentials:
raise HTTPException(401, "Требуется авторизация")
payload = verify_jwt(credentials.credentials)
if not payload:
raise HTTPException(401, "Недействительный токен")
user_id = payload["sub"]
conn = get_db()
try:
rows = conn.execute("""
SELECT p.code, p.expires_at, p.is_active, up.activated_at
FROM user_passes up
JOIN passes p ON up.pass_code = p.code
WHERE up.user_id = ?
""", (user_id,)).fetchall()
passes = []
now = time.time()
for row in rows:
expires = row["expires_at"]
is_active = row["is_active"] and (expires is None or expires > now)
passes.append({
"code": row["code"],
"activated_at": row["activated_at"],
"expires_at": expires,
"is_active": is_active
})
new_access_token = create_jwt({
"sub": user["id"],
"username": user["username"],
"uuid": user["uuid"],
"role": user["role"],
"type": "access",
"jti": session_token
})
log_audit(user["id"], "refresh_token", f"Token refreshed from {ip}", ip)
return {
"passes": passes,
"has_active": any(p["is_active"] for p in passes)
@@ -449,4 +643,4 @@ async def validate_token(request: Request, credentials: HTTPAuthorizationCredent
except Exception as e:
logger.error(f"Token validation error: {e}")
raise HTTPException(400, "Invalid request")
raise HTTPException(400, "Invalid request")
+33 -17
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from contextlib import asynccontextmanager
from pathlib import Path
@@ -9,6 +9,9 @@ import logging
from datetime import datetime
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
from cachetools import TTLCache
from urllib.parse import urlparse
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
from models import PackMeta
from middleware import LoggingMiddleware
@@ -21,7 +24,8 @@ import httpx
import base64
from fastapi.responses import StreamingResponse
from auth import router as auth_router, init_db, verify_jwt
from auth import get_current_user, router as auth_router, init_db, verify_jwt
from roles import Permissions, has_permission
logger = structlog.get_logger(__name__)
@@ -54,6 +58,8 @@ async def lifespan(app: FastAPI):
PACKS_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)
BLOCKED_HOSTS = []
init_db()
app.include_router(auth_router)
@@ -485,9 +491,16 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@app.get("/packs")
async def list_packs():
"""List all available packs"""
logger = logging.getLogger(__name__)
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="Для просмотра сборок требуется активная проходка"
)
packs = []
for pack_dir in PACKS_DIR.iterdir():
@@ -497,7 +510,6 @@ async def list_packs():
try:
with open(meta_path, 'r', encoding='utf-8') as f:
meta = json.load(f)
# Исправлено: конвертируем updated_at в строку если это datetime
updated_at = meta.get("updated_at")
if updated_at and isinstance(updated_at, datetime):
updated_at = updated_at.isoformat()
@@ -527,11 +539,22 @@ async def list_packs():
@app.post("/pack/{pack_name}/diff")
async def get_pack_diff(pack_name: str, request: Request):
"""
Client sends: { "mods/jei.jar": "sha256_hash", ... }
async def get_pack_diff(
pack_name: str,
request: Request,
current_user: dict = Depends(get_current_user) # Добавляем зависимость
):
"""Client sends: { "mods/jei.jar": "sha256_hash", ... }
Server returns diff information
"""
ТРЕБУЕТ ПРОХОДКУ ДЛЯ СКАЧИВАНИЯ"""
# Проверяем наличие проходки
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
raise HTTPException(
status_code=403,
detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору."
)
client_ip = request.client.host if request.client else "unknown"
# Читаем тело запроса
@@ -833,13 +856,8 @@ async def get_launcher_full_info():
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
# Кэш для часто запрашиваемых данных (5 минут)
from cachetools import TTLCache
proxy_cache = TTLCache(maxsize=50, ttl=300)
# Список заблокированных/проблемных хостов (можно обновлять)
BLOCKED_HOSTS = []
@app.get("/proxy/fabric/versions/loader")
async def proxy_fabric_versions(request: Request):
"""Прокси для Fabric Meta API - список версий загрузчика"""
@@ -886,7 +904,6 @@ async def proxy_fabric_installer_latest(request: Request):
xml = response.text
# Парсим последнюю версию из XML
import re
match = re.search(r'<latest>([^<]+)</latest>', xml)
if match:
version = match.group(1)
@@ -1084,7 +1101,6 @@ async def proxy_download(request: Request):
]
# Проверяем, что URL ведет на разрешенный домен
from urllib.parse import urlparse
parsed = urlparse(url)
domain = parsed.netloc.lower()
-77
View File
@@ -1,77 +0,0 @@
import json
from pathlib import Path
from datetime import datetime
import structlog
logger = structlog.get_logger(__name__)
PASSES_FILE = Path("data/passes.json")
def load_passes():
if not PASSES_FILE.exists():
PASSES_FILE.parent.mkdir(exist_ok=True)
default = {"passes": {}}
PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False))
return default
try:
return json.loads(PASSES_FILE.read_text(encoding="utf-8"))
except:
return {"passes": {}}
def save_passes(data):
PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
def activate_pass(pass_code: str, username: str, user_id: int) -> dict:
data = load_passes()
pass_code = pass_code.upper().strip()
if pass_code not in data["passes"]:
return {"success": False, "error": "Проходка не найдена"}
p = data["passes"][pass_code]
if not p.get("is_active", True):
return {"success": False, "error": "Проходка деактивирована"}
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
return {"success": False, "error": "Проходка истекла"}
if p.get("owner") is not None:
if p.get("owner") != username:
return {"success": False, "error": "Проходка уже активирована другим пользователем"}
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
# Активация
now = datetime.now().timestamp()
p["owner"] = username
p["activated_at"] = now
p["uses"] = p.get("uses", 0) + 1
save_passes(data)
logger.info("Pass activated", pass_code=pass_code, username=username)
return {"success": True, "message": "Проходка успешно активирована!"}
def has_active_pass(username: str) -> bool:
data = load_passes()
for p in data["passes"].values():
if p.get("owner") == username:
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
continue
if p.get("is_active", True):
return True
return False
def get_user_passes(username: str) -> list:
data = load_passes()
result = []
now = datetime.now().timestamp()
for p in data["passes"].values():
if p.get("owner") == username:
result.append({
"code": p["code"],
"activated_at": p.get("activated_at"),
"expires_at": p.get("expires_at"),
"is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now)
})
return result
+101
View File
@@ -0,0 +1,101 @@
# roles.py
from enum import IntEnum
from typing import Dict, Set
class UserRole(IntEnum):
USER = 0 # Обычный пользователь
PASS_HOLDER = 1 # Пользователь с проходкой
MODERATOR = 2 # Модератор
ELDER = 3 # Elder Moderator
CREATOR = 4 # Создатель
ROLE_NAMES: Dict[int, str] = {
UserRole.USER: "Игрок",
UserRole.PASS_HOLDER: "Игрок [Проходка]",
UserRole.MODERATOR: "Модератор",
UserRole.ELDER: "Elder Moderator",
UserRole.CREATOR: "Создатель"
}
# Права доступа
class Permissions:
# Базовые права
DOWNLOAD_PACK = "download_pack" # Скачивание сборок
VIEW_PACKS = "view_packs" # Просмотр списка сборок
# Права модератора
REQUEST_PASS = "request_pass" # Запрос проходки для игрока
VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей
# Права Elder Moderator
APPROVE_PASS = "approve_pass" # Одобрение проходок
REJECT_PASS = "reject_pass" # Отклонение проходок
VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок
MANAGE_MODERATORS = "manage_moderators" # Управление модераторами
# Права создателя
DIRECT_PASS = "direct_pass" # Прямая выдача проходки
MANAGE_ELDER = "manage_elder" # Управление Elder
MANAGE_SERVER = "manage_server" # Управление сервером
VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов
# Маппинг ролей на права
ROLE_PERMISSIONS: Dict[int, Set[str]] = {
UserRole.USER: {
# Обычный игрок НЕ может даже смотреть сборки!
# Только авторизоваться и смотреть свой профиль
},
UserRole.PASS_HOLDER: {
Permissions.VIEW_PACKS, # Может видеть список сборок
Permissions.DOWNLOAD_PACK, # Может скачивать сборки
},
UserRole.MODERATOR: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков
Permissions.VIEW_USER_LIST, # Может видеть список пользователей
},
UserRole.ELDER: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS,
Permissions.VIEW_USER_LIST,
Permissions.APPROVE_PASS, # Может одобрять проходки
Permissions.REJECT_PASS, # Может отклонять проходки
Permissions.VIEW_PASS_REQUESTS,
Permissions.MANAGE_MODERATORS, # Может управлять модераторами
},
UserRole.CREATOR: {
Permissions.VIEW_PACKS,
Permissions.DOWNLOAD_PACK,
Permissions.REQUEST_PASS,
Permissions.VIEW_USER_LIST,
Permissions.APPROVE_PASS,
Permissions.REJECT_PASS,
Permissions.VIEW_PASS_REQUESTS,
Permissions.MANAGE_MODERATORS,
Permissions.DIRECT_PASS, # Прямая выдача проходки
Permissions.MANAGE_ELDER, # Управление Elder
Permissions.MANAGE_SERVER, # Управление сервером
Permissions.VIEW_AUDIT_LOG, # Просмотр логов
}
}
def has_permission(role: int, permission: str) -> bool:
"""Проверка наличия права у роли"""
return permission in ROLE_PERMISSIONS.get(role, set())
def require_permission(permission: str):
"""Декоратор для проверки права"""
from functools import wraps
from fastapi import HTTPException, Depends
from auth import get_current_user
def decorator(func):
@wraps(func)
async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs):
if not has_permission(current_user["role"], permission):
raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}")
return await func(*args, current_user=current_user, **kwargs)
return wrapper
return decorator