diff --git a/cmd/server/main.go b/cmd/server/main.go index e3d9222..9991428 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,27 +1,44 @@ +// File: backend/cmd/server/main.go package main import ( "log" "net/http" + "gitea.mrixs.me/minecraft-platform/backend/internal/api" + "gitea.mrixs.me/minecraft-platform/backend/internal/core" + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) func main() { - // Создаем новый роутер - r := chi.NewRouter() + // Инициализируем соединение с БД + db := database.Connect() + defer db.Close() - // Используем стандартные middleware для логирования и восстановления после паник + // Собираем наши зависимости (Dependency Injection) + userRepo := &database.UserRepository{DB: db} + userService := &core.UserService{Repo: userRepo} + userHandler := &api.UserHandler{Service: userService} + + // Создаем роутер + r := chi.NewRouter() r.Use(middleware.Logger) r.Use(middleware.Recoverer) - // Определяем простой маршрут + // Группа маршрутов для API + r.Route("/api", func(r chi.Router) { + r.Post("/register", userHandler.Register) + // Здесь будут другие маршруты API + }) + + // Маршрут для проверки, что сервер жив r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Backend server is running!")) }) - // Запускаем сервер на порту 8080, как мы указали в Caddyfile log.Println("Starting backend server on :8080") if err := http.ListenAndServe(":8080", r); err != nil { log.Fatalf("Failed to start server: %v", err) diff --git a/go.mod b/go.mod index 9307b39..8f671ea 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,14 @@ module gitea.mrixs.me/minecraft-platform/backend go 1.24.1 -require github.com/go-chi/chi/v5 v5.2.1 // indirect +require ( + github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + 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 +) diff --git a/go.sum b/go.sum index 0b69c5c..81460b7 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,25 @@ +github.com/davecgh/go-spew v1.1.0/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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +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/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= +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= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +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= diff --git a/internal/api/user_handler.go b/internal/api/user_handler.go new file mode 100644 index 0000000..f47e072 --- /dev/null +++ b/internal/api/user_handler.go @@ -0,0 +1,42 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "gitea.mrixs.me/minecraft-platform/backend/internal/core" + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "gitea.mrixs.me/minecraft-platform/backend/internal/models" +) + +type UserHandler struct { + Service *core.UserService +} + +func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) { + var req models.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + 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 + case errors.Is(err, core.ErrInvalidUsername), errors.Is(err, core.ErrInvalidEmail), errors.Is(err, core.ErrPasswordTooShort): + http.Error(w, err.Error(), http.StatusBadRequest) // 400 + default: + // Логируем внутреннюю ошибку, но не показываем ее клиенту + // log.Printf("internal server error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) // 500 + } + return + } + + // Шаг 11 из ТЗ: Возвращаем 201 Created + w.WriteHeader(http.StatusCreated) +} diff --git a/internal/core/user_service.go b/internal/core/user_service.go new file mode 100644 index 0000000..26c6d40 --- /dev/null +++ b/internal/core/user_service.go @@ -0,0 +1,65 @@ +package core + +import ( + "context" + "errors" + "regexp" + + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidUsername = errors.New("invalid username format or length") + ErrInvalidEmail = errors.New("invalid email format") + 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 { + Repo *database.UserRepository +} + +// RegisterNewUser выполняет полный алгоритм регистрации +func (s *UserService) RegisterNewUser(ctx context.Context, req models.RegisterRequest) error { + // Шаг 2 из ТЗ: Валидация + if !usernameRegex.MatchString(req.Username) { + return ErrInvalidUsername + } + if !emailRegex.MatchString(req.Email) { + return ErrInvalidEmail + } + if len(req.Password) < 8 { + return ErrPasswordTooShort + } + + // Шаг 5 из ТЗ: Генерация хеша пароля + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12) // Стоимость 12, как в ТЗ + if err != nil { + return err + } + + // Шаг 6 из ТЗ: Генерация UUID + userUUID, err := uuid.NewRandom() + if err != nil { + return err + } + + user := &models.User{ + UUID: userUUID, + Username: req.Username, + Email: req.Email, + PasswordHash: string(passwordHash), + Role: "user", // По умолчанию + } + + // Вызываем метод репозитория для сохранения в БД + return s.Repo.CreateUserTx(ctx, user) +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..ee91270 --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,30 @@ +package database + +import ( + "database/sql" + "log" + "os" + + _ "github.com/jackc/pgx/v5/stdlib" // Регистрируем pgx драйвер +) + +// Connect устанавливает соединение с базой данных PostgreSQL +func Connect() *sql.DB { + connStr := os.Getenv("DATABASE_URL") + if connStr == "" { + log.Fatal("DATABASE_URL environment variable is not set") + } + + db, err := sql.Open("pgx", connStr) + if err != nil { + 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) + } + + log.Println("Successfully connected to PostgreSQL!") + return db +} diff --git a/internal/database/user_repository.go b/internal/database/user_repository.go new file mode 100644 index 0000000..64a9933 --- /dev/null +++ b/internal/database/user_repository.go @@ -0,0 +1,59 @@ +// File: backend/internal/database/user_repository.go +package database + +import ( + "context" + "database/sql" + "errors" + + "gitea.mrixs.me/minecraft-platform/backend/internal/models" +) + +var ( + ErrUserExists = errors.New("user with this username or email already exists") +) + +type UserRepository struct { + DB *sql.DB +} + +// CreateUserTx создает нового пользователя и его профиль в рамках одной транзакции +func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) error { + tx, err := r.DB.BeginTx(ctx, nil) + 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)", + user.Username, user.Email).Scan(&exists) + if err != nil { + return err + } + if exists { + 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", + user.UUID, user.Username, user.Email, user.PasswordHash, user.Role, + ).Scan(&newUserID) + if err != nil { + 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() +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..8819e8b --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,26 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// User представляет структуру пользователя в таблице 'users' +type User struct { + ID int `json:"-"` // Скрываем в JSON + UUID uuid.UUID `json:"uuid"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` // Пароль никогда не отдаем + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RegisterRequest определяет структуру JSON-запроса на регистрацию +type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` +}