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

@@ -34,7 +34,7 @@ func main() {
if keyPath == "" {
log.Fatal("RSA_PRIVATE_KEY_PATH environment variable is not set")
}
domain := os.Getenv("APP_DOMAIN") // Нам нужен домен для генерации URL
domain := os.Getenv("APP_DOMAIN")
if domain == "" {
log.Fatal("APP_DOMAIN environment variable is not set")
}
@@ -68,12 +68,10 @@ func main() {
// --- Защищенные роуты ---
r.Group(func(r chi.Router) {
// Применяем нашу middleware ко всем роутам в этой группе
r.Use(api.AuthMiddleware)
r.Route("/api/user", func(r chi.Router) {
r.Post("/skin", profileHandler.UploadSkin)
// Здесь будут другие эндпоинты для управления профилем
})
})

15
go.mod
View File

@@ -3,15 +3,18 @@ module gitea.mrixs.me/minecraft-platform/backend
go 1.24.1
require (
github.com/Tnze/go-mc v1.20.2 // indirect
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/Tnze/go-mc v1.20.2
github.com/go-chi/chi/v5 v5.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.5
golang.org/x/crypto v0.39.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/text v0.26.0 // indirect
)

7
go.sum
View File

@@ -1,6 +1,8 @@
github.com/Tnze/go-mc v1.20.2 h1:arHCE/WxLCxY73C/4ZNLdOymRYtdwoXE05ohB7HVN6Q=
github.com/Tnze/go-mc v1.20.2/go.mod h1:geoRj2HsXSkB3FJBuhr7wCzXegRlzWsVXd7h7jiJ6aQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@@ -15,10 +17,13 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
@@ -27,3 +32,5 @@ golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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"`