refactoring

This commit is contained in:
2025-07-17 03:45:44 +05:00
parent b79b0ae69f
commit 733977f56e
25 changed files with 300 additions and 363 deletions

0
app/api/__init__.py Normal file
View File

12
app/api/capes.py Normal file
View File

@ -0,0 +1,12 @@
from fastapi import APIRouter, UploadFile, File
from app.services.cape import CapeService
router = APIRouter(tags=["Capes"])
@router.post("/user/{username}/cape")
async def set_cape(username: str, cape_file: UploadFile = File(...)):
return await CapeService().set_cape(username, cape_file)
@router.delete("/user/{username}/cape")
async def remove_cape(username: str):
return await CapeService().remove_cape(username)

18
app/api/meta.py Normal file
View File

@ -0,0 +1,18 @@
from fastapi import APIRouter
router = APIRouter(tags=["Meta"])
@router.get("/")
def api_root():
return {
"meta": {
"serverName": "Your Auth Server",
"implementationName": "FastAPI",
"implementationVersion": "1.0.0",
"links": {
"homepage": "https://your-server.com"
},
},
"skinDomains": ["147.78.65.214"],
"capeDomains": ["147.78.65.214"]
}

12
app/api/skins.py Normal file
View File

@ -0,0 +1,12 @@
from fastapi import APIRouter, UploadFile, File, Form
from app.services.skin import SkinService
router = APIRouter(tags=["Skins"])
@router.post("/user/{username}/skin")
async def set_skin(username: str, skin_file: UploadFile = File(...), skin_model: str = Form("classic")):
return await SkinService().set_skin(username, skin_file, skin_model)
@router.delete("/user/{username}/skin")
async def remove_skin(username: str):
return await SkinService().remove_skin(username)

47
app/api/users.py Normal file
View File

@ -0,0 +1,47 @@
from fastapi import APIRouter, HTTPException, Body, Response
from app.models.user import UserCreate, UserLogin
from app.models.request import ValidateRequest
from app.services.auth import AuthService
router = APIRouter(
tags=["Users"]
)
@router.post("/auth/register")
async def register(user: UserCreate):
"""Регистрация нового пользователя"""
return await AuthService().register(user)
@router.post("/auth/authenticate")
async def authenticate(credentials: UserLogin):
"""Аутентификация пользователя"""
return await AuthService().login(credentials)
@router.post("/auth/validate")
async def validate_token(request: ValidateRequest):
is_valid = await AuthService().validate(request.accessToken, request.clientToken)
return {"valid": is_valid}
@router.post("/auth/refresh")
async def refresh_token(access_token: str, client_token: str):
result = await AuthService().refresh(access_token, client_token)
if not result:
raise HTTPException(status_code=401, detail="Invalid tokens")
return result
@router.get("/sessionserver/session/minecraft/profile/{uuid}")
async def get_minecraft_profile(uuid: str, unsigned: bool = False):
return await AuthService().get_minecraft_profile(uuid)
@router.post("/sessionserver/session/minecraft/join")
async def join_server(request_data: dict = Body(...)):
try:
await AuthService().join_server(request_data)
return Response(status_code=204)
except Exception as e:
print("Error in join_server:", str(e))
raise
@router.get("/sessionserver/session/minecraft/hasJoined")
async def has_joined(username: str, serverId: str):
return await AuthService().has_joined(username, serverId)

0
app/core/__init__.py Normal file
View File

11
app/core/config.py Normal file
View File

@ -0,0 +1,11 @@
from dotenv import load_dotenv
import os
from pathlib import Path
load_dotenv(dotenv_path=Path(__file__).parent.parent.parent / ".env")
FILES_URL = os.getenv("FILES_URL")
MONGO_URI = os.getenv("MONGO_URI")
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 часа

0
app/db/__init__.py Normal file
View File

8
app/db/database.py Normal file
View File

@ -0,0 +1,8 @@
from motor.motor_asyncio import AsyncIOMotorClient
from app.core.config import MONGO_URI
client = AsyncIOMotorClient(MONGO_URI)
db = client["minecraft_auth"]
users_collection = db["users"]
sessions_collection = db["sessions"]

0
app/models/__init__.py Normal file
View File

4
app/models/cape.py Normal file
View File

@ -0,0 +1,4 @@
from pydantic import BaseModel
class CapeUpdate(BaseModel):
cape_url: str

5
app/models/request.py Normal file
View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
class ValidateRequest(BaseModel):
accessToken: str # camelCase
clientToken: str

5
app/models/skin.py Normal file
View File

@ -0,0 +1,5 @@
from pydantic import BaseModel
from typing import Optional
class SkinUpdate(BaseModel):
skin_model: Optional[str] = "classic"

View File

@ -1,8 +1,7 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime from datetime import datetime
from typing import Optional
# Для запросов
class UserCreate(BaseModel): class UserCreate(BaseModel):
username: str username: str
email: EmailStr email: EmailStr
@ -12,31 +11,19 @@ class UserLogin(BaseModel):
username: str username: str
password: str password: str
# Для MongoDB
class UserInDB(BaseModel): class UserInDB(BaseModel):
username: str username: str
email: EmailStr email: EmailStr
hashed_password: str hashed_password: str
uuid: str uuid: str
skin_url: Optional[str] = None skin_url: Optional[str] = None
skin_model: Optional[str] = "classic" # "classic" или "slim" skin_model: Optional[str] = "classic"
cloak_url: Optional[str] = None cloak_url: Optional[str] = None
is_active: bool = True is_active: bool = True
created_at: datetime = datetime.utcnow() created_at: datetime = datetime.utcnow()
class Session(BaseModel): class Session(BaseModel):
access_token: str access_token: str
client_token: str client_token: str
user_uuid: str user_uuid: str
expires_at: datetime expires_at: datetime
class ValidateRequest(BaseModel):
accessToken: str # camelCase
clientToken: str
class SkinUpdate(BaseModel):
skin_model: Optional[str] = "classic" # "classic" или "slim"
# Удаляем skin_url и skin_file, так как будем принимать файл напрямую
class CapeUpdate(BaseModel):
cape_url: str

0
app/services/__init__.py Normal file
View File

View File

@ -2,14 +2,14 @@ import base64
import json import json
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .models import UserLogin, UserInDB, Session, UserCreate, SkinUpdate, CapeUpdate from app.models.user import UserLogin, UserInDB, UserCreate, Session
from .utils import ( from app.utils.misc import (
verify_password, verify_password,
get_password_hash, get_password_hash,
create_access_token, create_access_token,
decode_token, decode_token,
) )
from .database import users_collection, sessions_collection from ..db.database import users_collection, sessions_collection
import uuid import uuid
from datetime import datetime, timedelta from datetime import datetime, timedelta
from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives import serialization, hashes
@ -207,143 +207,3 @@ class AuthService:
"value": textures_value "value": textures_value
}] if textures else [] }] if textures else []
} }
async def set_skin(self, username: str, skin_file: UploadFile, skin_model: str = "classic"):
"""Установка или замена скина через загрузку файла"""
# Проверяем тип файла
if not skin_file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="File must be an image")
# Проверяем размер файла (максимум 2MB)
max_size = 2 * 1024 * 1024 # 2MB
contents = await skin_file.read()
if len(contents) > max_size:
raise HTTPException(status_code=400, detail="File too large (max 2MB)")
# Удаляем старый скин, если есть
user = await users_collection.find_one({"username": username})
if user and user.get("skin_url"):
from urllib.parse import urlparse
import os
old_url = user["skin_url"]
# Получаем имя файла из url
old_filename = os.path.basename(urlparse(old_url).path)
old_path = os.path.join("skins", old_filename)
if os.path.exists(old_path):
try:
os.remove(old_path)
except Exception:
pass
# Создаем папку для скинов, если ее нет
from pathlib import Path
skin_dir = Path("skins")
skin_dir.mkdir(exist_ok=True)
# Генерируем имя файла
skin_filename = f"{username}_{int(datetime.now().timestamp())}.png"
skin_path = skin_dir / skin_filename
# Сохраняем файл
with open(skin_path, "wb") as f:
f.write(contents)
# Обновляем запись пользователя
result = await users_collection.update_one(
{"username": username},
{"$set": {
"skin_url": f"{FILES_URL}/skins/{skin_filename}",
"skin_model": skin_model
}}
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success"}
async def remove_skin(self, username: str):
"""Удаление скина"""
user = await users_collection.find_one({"username": username})
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Удаляем файл скина, если он существует
if user.get("skin_url") and user["skin_url"].startswith("/skins/"):
import os
try:
os.remove(f"skins/{user['skin_url'].split('/')[-1]}")
except:
pass
result = await users_collection.update_one(
{"username": username},
{"$unset": {
"skin_url": "",
"skin_model": ""
}}
)
return {"status": "success"}
async def set_cape(self, username: str, cape_file: UploadFile):
"""Установка или замена плаща через загрузку файла (PNG или GIF)"""
if not cape_file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="File must be an image")
# Определяем расширение
ext = None
if cape_file.content_type == "image/png":
ext = "png"
elif cape_file.content_type == "image/gif":
ext = "gif"
else:
raise HTTPException(status_code=400, detail="Only PNG and GIF capes are supported")
max_size = 2 * 1024 * 1024 # 2MB
contents = await cape_file.read()
if len(contents) > max_size:
raise HTTPException(status_code=400, detail="File too large (max 2MB)")
# Удаляем старый плащ, если есть
user = await users_collection.find_one({"username": username})
if user and user.get("cloak_url"):
from urllib.parse import urlparse
import os
old_url = user["cloak_url"]
old_filename = os.path.basename(urlparse(old_url).path)
old_path = os.path.join("capes", old_filename)
if os.path.exists(old_path):
try:
os.remove(old_path)
except Exception:
pass
from pathlib import Path
cape_dir = Path("capes")
cape_dir.mkdir(exist_ok=True)
cape_filename = f"{username}_{int(datetime.now().timestamp())}.{ext}"
cape_path = cape_dir / cape_filename
with open(cape_path, "wb") as f:
f.write(contents)
result = await users_collection.update_one(
{"username": username},
{"$set": {
"cloak_url": f"{FILES_URL}/capes/{cape_filename}"
}}
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success"}
async def remove_cape(self, username: str):
"""Удаление плаща"""
result = await users_collection.update_one(
{"username": username},
{"$unset": {"cloak_url": ""}}
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success"}

70
app/services/cape.py Normal file
View File

@ -0,0 +1,70 @@
from app.db.database import users_collection
from app.core.config import FILES_URL
from fastapi import HTTPException, UploadFile
from datetime import datetime
class CapeService:
async def set_cape(self, username: str, cape_file: UploadFile):
"""Установка или замена плаща через загрузку файла (PNG или GIF)"""
if not cape_file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="File must be an image")
# Определяем расширение
ext = None
if cape_file.content_type == "image/png":
ext = "png"
elif cape_file.content_type == "image/gif":
ext = "gif"
else:
raise HTTPException(status_code=400, detail="Only PNG and GIF capes are supported")
max_size = 2 * 1024 * 1024 # 2MB
contents = await cape_file.read()
if len(contents) > max_size:
raise HTTPException(status_code=400, detail="File too large (max 2MB)")
# Удаляем старый плащ, если есть
user = await users_collection.find_one({"username": username})
if user and user.get("cloak_url"):
from urllib.parse import urlparse
import os
old_url = user["cloak_url"]
old_filename = os.path.basename(urlparse(old_url).path)
old_path = os.path.join("app/static/capes", old_filename)
if os.path.exists(old_path):
try:
os.remove(old_path)
except Exception:
pass
# Создаем папку для плащей, если ее нет
from pathlib import Path
cape_dir = Path("app/static/capes")
cape_dir.mkdir(parents=True, exist_ok=True)
cape_filename = f"{username}_{int(datetime.now().timestamp())}.{ext}"
cape_path = cape_dir / cape_filename
with open(cape_path, "wb") as f:
f.write(contents)
result = await users_collection.update_one(
{"username": username},
{"$set": {
"cloak_url": f"{FILES_URL}/capes/{cape_filename}"
}}
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success"}
async def remove_cape(self, username: str):
"""Удаление плаща"""
result = await users_collection.update_one(
{"username": username},
{"$unset": {"cloak_url": ""}}
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success"}

82
app/services/skin.py Normal file
View File

@ -0,0 +1,82 @@
from fastapi import HTTPException, UploadFile
from datetime import datetime
from app.db.database import users_collection
from app.core.config import FILES_URL
class SkinService:
async def set_skin(self, username: str, skin_file: UploadFile, skin_model: str = "classic"):
"""Установка или замена скина через загрузку файла"""
# Проверяем тип файла
if not skin_file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="File must be an image")
# Проверяем размер файла (максимум 2MB)
max_size = 2 * 1024 * 1024 # 2MB
contents = await skin_file.read()
if len(contents) > max_size:
raise HTTPException(status_code=400, detail="File too large (max 2MB)")
# Удаляем старый скин, если есть
user = await users_collection.find_one({"username": username})
if user and user.get("skin_url"):
from urllib.parse import urlparse
import os
old_url = user["skin_url"]
# Получаем имя файла из url
old_filename = os.path.basename(urlparse(old_url).path)
old_path = os.path.join("app/static/skins", old_filename)
print(f"Trying to delete old skin at: {old_path}")
if os.path.exists(old_path):
try:
os.remove(old_path)
except Exception:
pass
# Создаем папку для скинов, если ее нет
from pathlib import Path
skin_dir = Path("app/static/skins")
skin_dir.mkdir(parents=True, exist_ok=True)
# Генерируем имя файла
skin_filename = f"{username}_{int(datetime.now().timestamp())}.png"
skin_path = skin_dir / skin_filename
# Сохраняем файл
with open(skin_path, "wb") as f:
f.write(contents)
# Обновляем запись пользователя
result = await users_collection.update_one(
{"username": username},
{"$set": {
"skin_url": f"{FILES_URL}/skins/{skin_filename}",
"skin_model": skin_model
}}
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "success"}
async def remove_skin(self, username: str):
"""Удаление скина"""
user = await users_collection.find_one({"username": username})
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Удаляем файл скина, если он существует
if user.get("skin_url") and user["skin_url"].startswith("/skins/"):
import os
try:
os.remove(f"skins/{user['skin_url'].split('/')[-1]}")
except:
pass
result = await users_collection.update_one(
{"username": username},
{"$unset": {
"skin_url": "",
"skin_model": ""
}}
)
return {"status": "success"}

0
app/utils/__init__.py Normal file
View File

View File

@ -1,16 +1,7 @@
from jose import jwt, JWTError from jose import jwt, JWTError
from passlib.context import CryptContext from passlib.context import CryptContext
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os from app.core.config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
from pathlib import Path
from dotenv import load_dotenv
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 часа
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

View File

@ -1,17 +0,0 @@
from motor.motor_asyncio import AsyncIOMotorClient
from dotenv import load_dotenv
import os
from pathlib import Path
env_path = Path(__file__).parent.parent / ".env"
load_dotenv(dotenv_path=env_path)
MONGO_URI = os.getenv("MONGO_URI")
DB_NAME = "minecraft_auth"
client = AsyncIOMotorClient(MONGO_URI)
db = client[DB_NAME]
# Коллекции
users_collection = db["users"]
sessions_collection = db["sessions"]

View File

@ -1,19 +0,0 @@
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# Генерация ключа
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
# Сохранение в PEM-формат
with open("private_key.pem", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
with open("public_key.pem", "wb") as f:
f.write(private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
))

View File

@ -1,124 +0,0 @@
import base64
from datetime import datetime
import json
from fastapi import FastAPI, Depends, File, Form, HTTPException, Body, Request, Response, UploadFile
from fastapi.security import OAuth2PasswordBearer
from fastapi.staticfiles import StaticFiles
from .models import UserCreate, UserLogin, ValidateRequest, SkinUpdate, CapeUpdate
from .auth import AuthService
from .database import users_collection
from .utils import decode_token
import os
from pathlib import Path
from typing import Union
from fastapi.middleware.cors import CORSMiddleware
import logging
# logging.basicConfig(level=logging.DEBUG)
app = FastAPI()
auth_service = AuthService()
skin_dir = Path("skins")
skin_dir.mkdir(exist_ok=True)
app.mount("/skins", StaticFiles(directory="skins"), name="skins")
cape_dir = Path("capes")
cape_dir.mkdir(exist_ok=True)
app.mount("/capes", StaticFiles(directory="capes"), name="capes")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Разрешить все домены
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def api_root():
return {
"meta": {
"serverName": "Your Auth Server",
"implementationName": "FastAPI",
"implementationVersion": "1.0.0",
"links": {
"homepage": "https://your-server.com"
},
},
"skinDomains": ["147.78.65.214"],
"capeDomains": ["147.78.65.214"]
}
# Эндпоинты Mojang-like API
@app.post("/auth/register")
async def register(user: UserCreate):
return await auth_service.register(user)
@app.post("/auth/authenticate")
async def authenticate(credentials: UserLogin):
return await auth_service.login(credentials)
@app.post("/auth/validate")
async def validate_token(request: ValidateRequest):
is_valid = await auth_service.validate(request.accessToken, request.clientToken)
return {"valid": is_valid}
@app.post("/auth/refresh")
async def refresh_token(access_token: str, client_token: str):
result = await auth_service.refresh(access_token, client_token)
if not result:
raise HTTPException(status_code=401, detail="Invalid tokens")
return result
@app.get("/sessionserver/session/minecraft/profile/{uuid}")
async def get_minecraft_profile(uuid: str, unsigned: bool = False):
return await auth_service.get_minecraft_profile(uuid)
@app.post("/sessionserver/session/minecraft/join")
async def join_server(request_data: dict = Body(...)):
try:
await auth_service.join_server(request_data)
return Response(status_code=204)
except Exception as e:
print("Error in join_server:", str(e))
raise
@app.get("/sessionserver/session/minecraft/hasJoined")
async def has_joined(username: str, serverId: str):
return await auth_service.has_joined(username, serverId)
@app.post("/user/{username}/skin")
async def set_skin(
username: str,
skin_file: UploadFile = File(...),
skin_model: str = Form("classic")
):
return await auth_service.set_skin(username, skin_file, skin_model)
@app.delete("/user/{username}/skin")
async def remove_skin(username: str):
return await auth_service.remove_skin(username)
@app.post("/user/{username}/cape")
async def set_cape(
username: str,
cape_file: UploadFile = File(...)
):
return await auth_service.set_cape(username, cape_file)
@app.delete("/user/{username}/cape")
async def remove_cape(username: str):
return await auth_service.remove_cape(username)
@app.get("/debug/profile/{uuid}")
async def debug_profile(uuid: str):
profile = await auth_service.get_minecraft_profile(uuid)
textures = base64.b64decode(profile['properties'][0]['value']).decode()
return {
"profile": profile,
"textures_decoded": json.loads(textures)
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

53
main.py
View File

@ -1,40 +1,25 @@
from fastapi import FastAPI, HTTPException from fastapi import FastAPI
from aiomcrcon import Client, RCONConnectionError, IncorrectPasswordError from fastapi.staticfiles import StaticFiles
import asyncio from app.api import users, skins, capes, meta
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI() app = FastAPI()
# Конфигурация RCON (замените на свои данные) app.include_router(meta.router)
RCON_CONFIG = { app.include_router(users.router)
"hub": {"host": "minecraft.hub.popa-popa.ru", "port": 29001, "password": "2006siT_"}, app.include_router(skins.router)
"survival": {"host": "minecraft.survival.popa-popa.ru", "port": 25575, "password": "пароль_survival"}, app.include_router(capes.router)
"pillars": {"host": "minecraft.pillars.popa-popa.ru", "port": 29003, "password": "2006siT_"},
"velocity": {"host": "minecraft.velocity.popa-popa.ru", "port": 25575, "password": "пароль_velocity"}
}
async def send_rcon_command(server_type: str, command: str) -> str: # Монтируем статику
"""Отправляет RCON-команду на указанный сервер.""" app.mount("/skins", StaticFiles(directory="app/static/skins"), name="skins")
config = RCON_CONFIG.get(server_type) app.mount("/capes", StaticFiles(directory="app/static/capes"), name="capes")
if not config:
raise HTTPException(status_code=400, detail="Неверный тип сервера")
try: # CORS, middleware и т.д.
async with Client(config["host"], config["port"], config["password"]) as client:
response = await client.send_cmd(command)
return response
except RCONConnectionError:
raise HTTPException(status_code=503, detail="Не удалось подключиться к серверу")
except IncorrectPasswordError:
raise HTTPException(status_code=403, detail="Неверный пароль RCON")
@app.get("/rcon/") app.add_middleware(
async def execute_rcon(server_type: str, command: str): CORSMiddleware,
"""Выполняет RCON-команду на указанном сервере.""" allow_origins=["*"],
result = await send_rcon_command(server_type, command) allow_credentials=True,
return {"server": server_type, "command": command, "response": result} allow_methods=["*"],
allow_headers=["*"],
@app.get("/players/online/") )
async def get_online_players(server_type: str):
"""Возвращает список игроков онлайн на сервере."""
players = await send_rcon_command(server_type, "list")
return {"server": server_type, "online_players": players}