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):
# Преобразуем UUID без дефисов в формат с дефисами (если нужно)
if '-' not in uuid:
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})
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,
# Подписываем текстуры
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": [
{
"properties": [{
"name": "textures",
"value": base64_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,6 +163,13 @@ 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):
@ -141,7 +177,13 @@ class AuthService:
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"):
@ -151,16 +193,135 @@ class AuthService:
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

BIN
capes/DIKER_1752698970.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
capes/DIKER_1752699250.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
capes/DIKER_1752699326.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
capes/DIKER_1752699356.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
capes/DIKER_1752699438.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
capes/DIKER_1752699669.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

28
private_key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC1olpnwfijhTT9
vRR/AzZc5t4DYM3AOMRf0rdBGn5YWzvzk6LQaWLV7PAycUehzvZmSD5Ko5leg4mm
pCVod/bm+U99fofaW+MpQ/T+UEg2q3FAkVTmyTts6lQaDXgkOckLj4SQcKemn/9l
HDL5czDfpUnxBA5ONuTiBiQpxYAQHLk9r2cxIzyDk6veBzAvg6W9eBg1AAs8WJXo
zo9EwM0JustDgsLgK+x+g43U9pHSF9DoWBCoICYeShDl2DMQQFO4DW6v/E4qGgI9
BGii3ef4glXJ9OwJv0JlE5Cg3FaHgyFm0I+NroDurIED7JiL4k7UXw+Bb22KrL9Z
tgGggzspAgMBAAECggEAOTj/8GZc1e92hWYXWfiCHPyi/z91MtTvkRzKnRkiquV7
Wr6tcalx+OGfvtSPc7vHRuwFq/AktnEMYdKe8m2w/I2Y7Hl7hWCjjXGacrCKP6b9
lBD1RYwqS6L7ggWyTv9hhmHdqr/DIayQgqNCr/IJeLwTMnpLo3qJ22eB5yMQuII6
inhTbL/uCvoU2R2gfMQRjtC2GIXApjWJwz20Ydv6B9X+jdw431n5ZNresyR6Chfx
tVCku+LZ7gpbX/t7B6glYZ/B+CGO8RglQwW67YEYfgxaEHqoYy7yKEaOYRv2BXKA
T6Aj7+0z0P2kV067eVCu8tf4WK/ji44K1uNEVmbxnQKBgQD8nFhFz+AXULNnjJuf
RwE2WuxmXKkL3fCYp/PlN9+WznoNfRLyMxYI5tJe7yqoBj1S7caBTS9JkPTpEIhQ
qsJaDdvEzNm62TxeArUs9KO/IPI5P8P616W4z2YWYFGHrKpJz3KXfOP/DMYcyCG3
cmzPjKaQhGyi3TXPh1sbgmCbSwKBgQC4Eji3mqCpDLxJlctQs67uQ6gdNEePDDL/
Kl6BoqQ8RaSSh5PAHdFzRefeKxRSEF63ZRVJBaqZadS/Wx9qu6ZbpMx7NO+6B4/7
mcf2OBPr3/ioVuaAOAwOPNsCtMPnQDbETAnGBddRj8Bo2+YF75xaIGR0N8qI+Gic
dvx9rGvm2wKBgQD0JHb8IgjG/+wkrDTMH+gADKhl1jBbk8kxAUIry3CBZFV6K+Pf
yZgGSnAP6L8lXcJvH/e2iE6nnz3U83GL5T2po7M/5WyZtdMuWReZt2d7FfCFfCeB
jGJS18Am6DhkFHEQnTp3RvFkU4g10QclMaYQgjOJgTMtxPZ4+K0JTVzpOQKBgCor
Un8Nl5zi5Affn1J/t6WyLkNyhKpK2ywF4tzEC+ga9Fb1ZG3w5tkHvNTy/ZbHVUui
hrvR5oF681hbYdkr4DLCkG3xdLIjpWK4mkzYEAhLqUW3ktrw/CIO4wW9r9u8pE9Y
NCz/jZKL4kKjjhDyEdm77geJ+IZkkmK2B6Yq6BVdAoGAPlSRQFCo3ZKk78wJm19j
IjSacufzhQyG9G6US0Ql0HEqsjo+T2ZOPhge2zkfxs+sr6EQrbEBOoTV3IOtet78
x6u/IxD1QfJQtIAS8n4s6+HEFV7Gu+zvkz9dIIN6nMKZb1tfZlMmEtdm0Ms9kFma
1eyyqBR6aOhWwYopsHhdCOc=
-----END PRIVATE KEY-----

9
public_key.pem Normal file
View File

@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtaJaZ8H4o4U0/b0UfwM2
XObeA2DNwDjEX9K3QRp+WFs785Oi0Gli1ezwMnFHoc72Zkg+SqOZXoOJpqQlaHf2
5vlPfX6H2lvjKUP0/lBINqtxQJFU5sk7bOpUGg14JDnJC4+EkHCnpp//ZRwy+XMw
36VJ8QQOTjbk4gYkKcWAEBy5Pa9nMSM8g5Or3gcwL4OlvXgYNQALPFiV6M6PRMDN
CbrLQ4LC4CvsfoON1PaR0hfQ6FgQqCAmHkoQ5dgzEEBTuA1ur/xOKhoCPQRoot3n
+IJVyfTsCb9CZROQoNxWh4MhZtCPja6A7qyBA+yYi+JO1F8PgW9tiqy/WbYBoIM7
KQIDAQAB
-----END PUBLIC KEY-----

BIN
skins/DIKER_1752693632.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
skins/DIKER_1752698381.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB