feat: working skins and capes(animated capes not :( )

This commit is contained in:
2025-07-17 02:07:32 +05:00
parent 3d310760ba
commit 9786d6d9b1
14 changed files with 307 additions and 37 deletions

View File

@ -1,7 +1,8 @@
import base64
import json
from fastapi import HTTPException
from .models import UserLogin, UserInDB, Session, UserCreate
from fastapi import HTTPException, UploadFile
from fastapi.responses import JSONResponse
from .models import UserLogin, UserInDB, Session, UserCreate, SkinUpdate, CapeUpdate
from .utils import (
verify_password,
get_password_hash,
@ -11,6 +12,14 @@ from .utils import (
from .database import users_collection, sessions_collection
import uuid
from datetime import datetime, timedelta
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from dotenv import load_dotenv
import os
load_dotenv()
FILES_URL = os.getenv("FILES_URL")
class AuthService:
async def register(self, user: UserCreate):
@ -86,41 +95,61 @@ class AuthService:
return {"accessToken": new_access_token, "clientToken": client_token}
async def get_minecraft_profile(self, uuid: str):
formatted_uuid = f"{uuid[:8]}-{uuid[8:12]}-{uuid[12:16]}-{uuid[16:20]}-{uuid[20:]}"
user = await users_collection.find_one({"uuid": formatted_uuid})
# Преобразуем UUID без дефисов в формат с дефисами (если нужно)
if '-' not in uuid:
formatted_uuid = f"{uuid[:8]}-{uuid[8:12]}-{uuid[12:16]}-{uuid[16:20]}-{uuid[20:]}"
else:
formatted_uuid = uuid
user = await users_collection.find_one({"uuid": formatted_uuid}) # Ищем по UUID с дефисами
if not user:
raise HTTPException(
status_code=404,
detail=f"User not found (searched for UUID: {formatted_uuid})"
)
raise HTTPException(status_code=404, detail="User not found")
textures = {
"timestamp": int(datetime.now().timestamp()),
"profileId": formatted_uuid,
"timestamp": int(datetime.now().timestamp() * 1000),
"profileId": user["uuid"], # UUID с дефисами
"profileName": user["username"],
"textures": {
"SKIN": {"url": user.get("skin_url", "")},
"CAPE": {"url": user.get("cloak_url", "")}
} if user.get("skin_url") or user.get("cloak_url") else {}
"textures": {}
}
if user.get("skin_url"):
textures["textures"]["SKIN"] = {
"url": user["skin_url"],
"metadata": {"model": user.get("skin_model", "classic")}
}
if user.get("cloak_url"):
textures["textures"]["CAPE"] = {"url": user["cloak_url"]}
textures_json = json.dumps(textures).encode()
base64_textures = base64.b64encode(textures_json).decode()
return {
"id": formatted_uuid,
"name": user["username"],
"properties": [
{
"name": "textures",
"value": base64_textures
}
]
}
# Подписываем текстуры
with open("private_key.pem", "rb") as key_file:
private_key = serialization.load_pem_private_key(
key_file.read(),
password=None
)
signature = private_key.sign(
textures_json,
padding.PKCS1v15(),
hashes.SHA1()
)
return JSONResponse({
"id": user["uuid"].replace("-", ""), # Уберите дефисы
"name": user["username"],
"properties": [{
"name": "textures",
"value": base64_textures,
"signature": base64.b64encode(signature).decode()
}]
})
async def join_server(self, request_data: dict):
access_token = request_data.get("accessToken")
selected_profile = request_data.get("selectedProfile")
selected_profile = request_data.get("selectedProfile") # UUID без дефисов
server_id = request_data.get("serverId")
if not all([access_token, selected_profile, server_id]):
@ -134,33 +163,165 @@ class AuthService:
if token_uuid != selected_profile:
raise HTTPException(status_code=403, detail="Token doesn't match selected profile")
# Сохраняем server_id в сессию
await sessions_collection.update_one(
{"user_uuid": decoded_token["uuid"]}, # UUID с дефисами
{"$set": {"server_id": server_id}},
upsert=True
)
return True
async def has_joined(self, username: str, server_id: str):
user = await users_collection.find_one({"username": username})
if not user:
raise HTTPException(status_code=404, detail="User not found")
response_uuid = user["uuid"].replace("-", "")
# Ищем сессию с этим server_id
session = await sessions_collection.find_one({
"user_uuid": user["uuid"], # UUID с дефисами
"server_id": server_id
})
if not session:
raise HTTPException(status_code=403, detail="Not joined this server")
textures = {}
if user.get("skin_url"):
textures["SKIN"] = {"url": user["skin_url"]}
if user.get("cloak_url"):
textures["CAPE"] = {"url": user["cloak_url"]}
textures_value = base64.b64encode(json.dumps({
"timestamp": int(datetime.now().timestamp()),
"profileId": response_uuid,
"profileId": user["uuid"].replace("-", ""), # UUID без дефисов
"profileName": username,
"textures": textures
}).encode()).decode()
return {
"id": response_uuid,
"id": user["uuid"].replace("-", ""), # UUID без дефисов
"name": username,
"properties": [{
"name": "textures",
"value": textures_value
}] 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)")
# Создаем папку для скинов, если ее нет
import os
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")
# Проверяем размер файла (максимум 2MB)
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)")
# Создаем папку для плащей, если ее нет
import os
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"}

19
auth/app/generate_key.py Normal file
View File

@ -0,0 +1,19 @@
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,13 +1,15 @@
import base64
from datetime import datetime
import json
from fastapi import FastAPI, Depends, HTTPException, Body, Request, Response
from fastapi import FastAPI, Depends, File, Form, HTTPException, Body, Request, Response, UploadFile
from fastapi.security import OAuth2PasswordBearer
from .models import UserCreate, UserLogin, ValidateRequest
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
@ -16,6 +18,14 @@ import logging
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=["*"], # Разрешить все домены
@ -32,8 +42,10 @@ def api_root():
"implementationVersion": "1.0.0",
"links": {
"homepage": "https://your-server.com"
}
}
},
},
"skinDomains": ["147.78.65.214"],
"capeDomains": ["147.78.65.214"]
}
# Эндпоинты Mojang-like API
@ -74,6 +86,39 @@ async def join_server(request_data: dict = Body(...)):
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)

View File

@ -19,6 +19,7 @@ class UserInDB(BaseModel):
hashed_password: str
uuid: str
skin_url: Optional[str] = None
skin_model: Optional[str] = "classic" # "classic" или "slim"
cloak_url: Optional[str] = None
is_active: bool = True
created_at: datetime = datetime.utcnow()
@ -32,3 +33,10 @@ class Session(BaseModel):
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