small fixes

This commit is contained in:
2025-06-18 09:01:14 +03:00
parent 42f2b68848
commit 5e609017f0
15 changed files with 55 additions and 108 deletions

View File

@@ -59,7 +59,6 @@ func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) {
err := h.Service.ValidateJoinRequest(r.Context(), req)
if err != nil {
// Yggdrasil ожидает 403 Forbidden при невалидной сессии
if errors.Is(err, core.ErrInvalidCredentials) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
@@ -75,7 +74,6 @@ func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) {
return
}
// В случае успеха возвращаем пустой ответ со статусом 204
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/golang-jwt/jwt/v5"
)
// contextKey - это тип для ключей контекста, чтобы избежать коллизий.
type contextKey string
const UserIDContextKey = contextKey("userID")
@@ -24,7 +23,7 @@ func AuthMiddleware(next http.Handler) http.Handler {
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader { // Префикс "Bearer " не найден
if tokenString == authHeader {
http.Error(w, "Invalid token format", http.StatusUnauthorized)
return
}
@@ -36,7 +35,6 @@ func AuthMiddleware(next http.Handler) http.Handler {
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Проверяем, что метод подписи HMAC
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
@@ -54,7 +52,6 @@ func AuthMiddleware(next http.Handler) http.Handler {
return
}
// Получаем user_id из claims. JWT хранит числа как float64.
userIDFloat, ok := claims["user_id"].(float64)
if !ok {
http.Error(w, "Invalid user_id in token", http.StatusUnauthorized)
@@ -62,7 +59,6 @@ func AuthMiddleware(next http.Handler) http.Handler {
}
userID := int(userIDFloat)
// Добавляем userID в контекст запроса для использования в хендлере
ctx := context.WithValue(r.Context(), UserIDContextKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@@ -26,7 +26,6 @@ func (h *ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.Service.GetSignedProfile(r.Context(), playerUUID)
if err != nil {
if errors.Is(err, database.ErrUserNotFound) {
// Yggdrasil возвращает 204 No Content, если профиль не найден
w.WriteHeader(http.StatusNoContent)
return
}
@@ -40,17 +39,15 @@ func (h *ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
}
func (h *ProfileHandler) UploadSkin(w http.ResponseWriter, r *http.Request) {
// Получаем userID из контекста, который был добавлен middleware
userID, ok := r.Context().Value(UserIDContextKey).(int)
if !ok {
http.Error(w, "Could not get user ID from context", http.StatusInternalServerError)
return
}
// Ограничиваем размер загружаемого файла (например, 16KB)
r.ParseMultipartForm(16 << 10) // 16KB
r.ParseMultipartForm(256 << 10) // 256KB
file, header, err := r.FormFile("skin") // "skin" - это имя поля в форме
file, header, err := r.FormFile("skin")
if err != nil {
http.Error(w, "Invalid file upload", http.StatusBadRequest)
return
@@ -59,7 +56,6 @@ func (h *ProfileHandler) UploadSkin(w http.ResponseWriter, r *http.Request) {
err = h.Service.UpdateUserSkin(r.Context(), userID, file, header)
if err != nil {
// Можно добавить более детальную обработку ошибок
http.Error(w, err.Error(), http.StatusBadRequest)
return
}

View File

@@ -23,20 +23,16 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
err := h.Service.RegisterNewUser(r.Context(), req)
if err != nil {
// Определяем, какую ошибку вернуть клиенту
switch {
case errors.Is(err, database.ErrUserExists):
http.Error(w, err.Error(), http.StatusConflict) // 409
http.Error(w, err.Error(), http.StatusConflict)
case errors.Is(err, core.ErrInvalidUsername), errors.Is(err, core.ErrInvalidEmail), errors.Is(err, core.ErrPasswordTooShort):
http.Error(w, err.Error(), http.StatusBadRequest) // 400
http.Error(w, err.Error(), http.StatusBadRequest)
default:
// Логируем внутреннюю ошибку, но не показываем ее клиенту
// log.Printf("internal server error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) // 500
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
return
}
// Шаг 11 из ТЗ: Возвращаем 201 Created
w.WriteHeader(http.StatusCreated)
}

View File

@@ -24,34 +24,28 @@ type AuthService struct {
// Authenticate проверяет учетные данные и возвращает данные для ответа Yggdrasil.
func (s *AuthService) Authenticate(ctx context.Context, req models.AuthenticateRequest) (*models.AuthenticateResponse, error) {
// 1. Найти пользователя по имени
user, err := s.UserRepo.GetUserByUsername(ctx, req.Username)
if err != nil {
if errors.Is(err, database.ErrUserNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err // Другая ошибка БД
return nil, err
}
// 2. Сравнить хеш пароля из БД с паролем из запроса
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil {
// Если хеши не совпадают, bcrypt возвращает ошибку
return nil, ErrInvalidCredentials
}
// 3. Сгенерировать новый accessToken
accessToken := uuid.New().String()
// 4. Сохранить токен в БД
err = s.UserRepo.CreateAccessToken(ctx, user.ID, accessToken, req.ClientToken)
if err != nil {
return nil, err
}
// 5. Сформировать ответ согласно спецификации Yggdrasil
profile := models.ProfileInfo{
ID: strings.ReplaceAll(user.UUID.String(), "-", ""), // UUID без дефисов
ID: strings.ReplaceAll(user.UUID.String(), "-", ""),
Name: user.Username,
}
@@ -71,26 +65,21 @@ func (s *AuthService) Authenticate(ctx context.Context, req models.AuthenticateR
// LoginUser проверяет учетные данные и генерирует JWT для веб-сессии.
func (s *AuthService) LoginUser(ctx context.Context, req models.LoginRequest) (string, *models.User, error) {
// 1. Найти пользователя по логину (username или email)
user, err := s.UserRepo.GetUserByLogin(ctx, req.Login)
if err != nil {
if errors.Is(err, database.ErrUserNotFound) {
return "", nil, ErrInvalidCredentials
}
return "", nil, err // Другая ошибка БД
return "", nil, err
}
// 2. Сравнить хеш пароля
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
if err != nil {
return "", nil, ErrInvalidCredentials
}
// 3. Создать JWT
// Устанавливаем срок действия токена, например, 72 часа
expirationTime := time.Now().Add(72 * time.Hour)
// Создаем claims (полезная нагрузка токена)
claims := &jwt.MapClaims{
"exp": expirationTime.Unix(),
"iat": time.Now().Unix(),
@@ -98,17 +87,14 @@ func (s *AuthService) LoginUser(ctx context.Context, req models.LoginRequest) (s
"role": user.Role,
}
// Создаем токен с указанием алгоритма подписи и claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Подписываем токен нашим секретным ключом
jwtSecret := os.Getenv("JWT_SECRET_KEY")
tokenString, err := token.SignedString([]byte(jwtSecret))
if err != nil {
return "", nil, err
}
// Скрываем хеш пароля перед отправкой данных пользователя на клиент
user.PasswordHash = ""
return tokenString, user, nil

View File

@@ -8,12 +8,12 @@ import (
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/hex" // Для преобразования хеша в строку
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"image/png" // Для валидации PNG
"image/png"
"io"
"mime/multipart"
"os"
@@ -45,7 +45,6 @@ func NewProfileService(repo *database.UserRepository, keyPath, domain string) (*
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
// Попробуем PKCS#8, если PKCS#1 не удался
key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes)
if errPkcs8 != nil {
return nil, fmt.Errorf("failed to parse private key: %v / %v", err, errPkcs8)
@@ -71,7 +70,6 @@ func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.U
return nil, err
}
// 1. Формируем структуру со свойствами текстур
textures := models.Textures{}
if profile.SkinHash != "" {
textures.SKIN = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.SkinHash)}
@@ -88,14 +86,12 @@ func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.U
Textures: textures,
}
// 2. Маршализация в JSON и кодирование в Base64
valueJSON, err := json.Marshal(propValue)
if err != nil {
return nil, err
}
valueBase64 := base64.StdEncoding.EncodeToString(valueJSON)
// 3. Подпись
hasher := sha1.New()
hasher.Write([]byte(valueBase64))
hashed := hasher.Sum(nil)
@@ -106,7 +102,6 @@ func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.U
}
signatureBase64 := base64.StdEncoding.EncodeToString(signature)
// 4. Формирование итогового ответа
response := &models.SessionProfileResponse{
ID: profileID,
Name: user.Username,
@@ -124,15 +119,12 @@ func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.U
// UpdateUserSkin обрабатывает загрузку, валидацию и сохранение файла скина.
func (s *ProfileService) UpdateUserSkin(ctx context.Context, userID int, file multipart.File, header *multipart.FileHeader) error {
// 1. Читаем файл в память
fileBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// Возвращаем указатель файла в начало, чтобы его можно было прочитать снова
file.Seek(0, 0)
// 2. Валидация PNG 64x64
config, err := png.DecodeConfig(file)
if err != nil {
return errors.New("invalid PNG file")
@@ -141,20 +133,16 @@ func (s *ProfileService) UpdateUserSkin(ctx context.Context, userID int, file mu
return errors.New("skin must be 64x64 pixels")
}
// 3. Вычисляем SHA1 хеш
hasher := sha1.New()
hasher.Write(fileBytes)
hash := hex.EncodeToString(hasher.Sum(nil))
// 4. Сохраняем файл на диск
// Путь к хранилищу текстур должен быть конфигурируемым
storagePath := os.Getenv("TEXTURES_STORAGE_PATH")
if storagePath == "" {
return errors.New("textures storage path not configured")
}
filePath := fmt.Sprintf("%s/%s", storagePath, hash)
// Создаем файл только если его еще нет
if _, err := os.Stat(filePath); os.IsNotExist(err) {
outFile, err := os.Create(filePath)
if err != nil {
@@ -168,6 +156,5 @@ func (s *ProfileService) UpdateUserSkin(ctx context.Context, userID int, file mu
}
}
// 5. Сохраняем хеш в БД
return s.UserRepo.UpdateSkinHash(ctx, userID, hash)
}

View File

@@ -8,10 +8,22 @@ import (
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
"github.com/Tnze/go-mc/bot/basic"
"github.com/Tnze/go-mc/net"
"github.com/Tnze/go-mc/bot"
"github.com/Tnze/go-mc/chat"
)
type pingResponse struct {
Description chat.Message `json:"description"`
Players struct {
Max int `json:"max"`
Online int `json:"online"`
} `json:"players"`
Version struct {
Name string `json:"name"`
Protocol int `json:"protocol"`
} `json:"version"`
}
type ServerPoller struct {
Repo *database.ServerRepository
}
@@ -47,29 +59,19 @@ func (p *ServerPoller) pollAllServers(ctx context.Context) {
}
func (p *ServerPoller) pollServer(ctx context.Context, server *models.GameServer) {
resp, delay, err := net.PingAndListTimeout(server.Address, 5*time.Second)
resp, delay, err := bot.PingAndList(server.Address)
if err != nil {
log.Printf("Poller: failed to ping %s (%s): %v", server.Name, server.Address, err)
return
}
var status basic.ServerList
var status pingResponse
if err := json.Unmarshal(resp, &status); err != nil {
log.Printf("Poller: failed to unmarshal status for %s: %v", server.Name, err)
return
}
// MOTD может быть сложным объектом, извлекаем текст
var motdText string
if s, ok := status.Description.(string); ok {
motdText = s
} else {
if m, ok := status.Description.(map[string]interface{}); ok {
if t, ok := m["text"].(string); ok {
motdText = t
}
}
}
motdText := status.Description.String()
updateData := &models.ServerStatus{
StatusJSON: string(resp),

View File

@@ -19,10 +19,8 @@ var (
ErrPasswordTooShort = errors.New("password is too short (minimum 8 characters)")
)
// Регулярное выражение для валидации username
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,16}$`)
// Регулярное выражение для валидации email (упрощенное, но эффективное)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
type UserService struct {
@@ -31,7 +29,6 @@ type UserService struct {
// RegisterNewUser выполняет полный алгоритм регистрации
func (s *UserService) RegisterNewUser(ctx context.Context, req models.RegisterRequest) error {
// Шаг 2 из ТЗ: Валидация
if !usernameRegex.MatchString(req.Username) {
return ErrInvalidUsername
}
@@ -42,13 +39,11 @@ func (s *UserService) RegisterNewUser(ctx context.Context, req models.RegisterRe
return ErrPasswordTooShort
}
// Шаг 5 из ТЗ: Генерация хеша пароля
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12) // Стоимость 12, как в ТЗ
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
return err
}
// Шаг 6 из ТЗ: Генерация UUID
userUUID, err := uuid.NewRandom()
if err != nil {
return err
@@ -59,16 +54,14 @@ func (s *UserService) RegisterNewUser(ctx context.Context, req models.RegisterRe
Username: req.Username,
Email: req.Email,
PasswordHash: string(passwordHash),
Role: "user", // По умолчанию
Role: "user",
}
// Вызываем метод репозитория для сохранения в БД
return s.Repo.CreateUserTx(ctx, user)
}
// ValidateJoinRequest проверяет запрос на присоединение к серверу.
func (s *AuthService) ValidateJoinRequest(ctx context.Context, req models.JoinRequest) error {
// Преобразуем UUID из строки без дефисов в стандартный формат
var uuidStr string
if len(req.SelectedProfile) == 32 {
uuidStr = fmt.Sprintf("%s-%s-%s-%s-%s",
@@ -87,18 +80,14 @@ func (s *AuthService) ValidateJoinRequest(ctx context.Context, req models.JoinRe
return fmt.Errorf("failed to parse profile UUID: %w", err)
}
// Проверяем токен в базе данных
err = s.UserRepo.ValidateAccessToken(ctx, req.AccessToken, userUUID)
if err != nil {
if errors.Is(err, database.ErrTokenNotFound) {
// Возвращаем ту же ошибку, что и при неверных кредах, чтобы не давать лишней информации
return ErrInvalidCredentials
}
return err
}
// В ТЗ указано "привязка serverId". В простой реализации это может быть просто логирование
// или запись в кеш (например, Redis). Для начала, просто прохождение валидации достаточно.
log.Printf("User %s successfully joined server with serverId %s", userUUID, req.ServerID)
return nil

View File

@@ -5,7 +5,7 @@ import (
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib" // Регистрируем pgx драйвер
_ "github.com/jackc/pgx/v5/stdlib"
)
// Connect устанавливает соединение с базой данных PostgreSQL
@@ -20,7 +20,6 @@ func Connect() *sql.DB {
log.Fatalf("Unable to connect to database: %v\n", err)
}
// Проверяем, что соединение действительно установлено
if err = db.Ping(); err != nil {
log.Fatalf("Unable to ping database: %v\n", err)
}

View File

@@ -23,10 +23,8 @@ func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) er
if err != nil {
return err
}
// Гарантируем откат транзакции в случае любой ошибки
defer tx.Rollback()
// Шаг 4 из ТЗ: Проверка уникальности
var exists bool
err = tx.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)",
@@ -38,7 +36,6 @@ func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) er
return ErrUserExists
}
// Шаг 7 из ТЗ: INSERT в таблицу users. Получаем ID нового пользователя.
var newUserID int
err = tx.QueryRowContext(ctx,
"INSERT INTO users (uuid, username, email, password_hash, role) VALUES ($1, $2, $3, $4, $5) RETURNING id",
@@ -48,22 +45,18 @@ func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) er
return err
}
// Шаг 9 из ТЗ: INSERT в таблицу profiles
_, err = tx.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES ($1)", newUserID)
if err != nil {
return err
}
// Шаг 10 из ТЗ: Коммитим транзакцию
return tx.Commit()
}
var (
ErrUserNotFound = errors.New("user not found") // Новая ошибка
ErrUserNotFound = errors.New("user not found")
)
// ...
// GetUserByUsername находит пользователя по его имени.
// Возвращает полную структуру User, включая хеш пароля для проверки.
func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
@@ -97,7 +90,7 @@ func (r *UserRepository) CreateAccessToken(ctx context.Context, userID int, acce
func (r *UserRepository) GetProfileByUUID(ctx context.Context, userUUID uuid.UUID) (*models.User, *models.Profile, error) {
user := &models.User{UUID: userUUID}
profile := &models.Profile{}
var skinHash, capeHash sql.NullString // Используем NullString для полей, которые могут быть NULL
var skinHash, capeHash sql.NullString
query := `
SELECT u.id, u.username, p.skin_hash, p.cape_hash
@@ -127,8 +120,7 @@ func (r *UserRepository) GetProfileByUUID(ctx context.Context, userUUID uuid.UUI
}
var (
// ...
ErrTokenNotFound = errors.New("access token not found or invalid") // Новая ошибка
ErrTokenNotFound = errors.New("access token not found or invalid")
)
// ValidateAccessToken проверяет, действителен ли токен для данного пользователя.
@@ -169,7 +161,7 @@ func (r *UserRepository) UpdateSkinHash(ctx context.Context, userID int, skinHas
return err
}
if rowsAffected == 0 {
return ErrUserNotFound // Если профиль для user_id не найден
return ErrUserNotFound
}
return nil
@@ -180,7 +172,6 @@ func (r *UserRepository) GetUserByLogin(ctx context.Context, login string) (*mod
user := &models.User{}
var userUUID string
// Ищем по username ИЛИ по email
query := "SELECT id, uuid, username, email, password_hash, role, created_at, updated_at FROM users WHERE username = $1 OR email = $1"
err := r.DB.QueryRowContext(ctx, query, login).Scan(
&user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt,

View File

@@ -77,7 +77,6 @@ type JoinRequest struct {
// LoginRequest - это тело запроса на /api/login
type LoginRequest struct {
// Позволяем логиниться как по username, так и по email
Login string `json:"login"`
Password string `json:"password"`
}
@@ -85,5 +84,5 @@ type LoginRequest struct {
// LoginResponse - это тело успешного ответа с JWT
type LoginResponse struct {
Token string `json:"token"`
User *User `json:"user"` // Отдаем информацию о пользователе
User *User `json:"user"`
}

View File

@@ -8,11 +8,11 @@ import (
// User представляет структуру пользователя в таблице 'users'
type User struct {
ID int `json:"-"` // Скрываем в JSON
ID int `json:"-"`
UUID uuid.UUID `json:"uuid"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"-"` // Пароль никогда не отдаем
PasswordHash string `json:"-"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`