From ca182d6d6f5175eebb1220740e917cadfa6b68a9 Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Wed, 18 Jun 2025 12:41:27 +0300 Subject: [PATCH] feat(modpacks): implement simple zip importer and API --- cmd/server/main.go | 17 ++++-- internal/api/modpack_handler.go | 68 +++++++++++++++++++++ internal/core/importer/importer.go | 9 +++ internal/core/importer/simple_zip.go | 78 +++++++++++++++++++++++++ internal/database/modpack_repository.go | 49 ++++++++++++++++ internal/database/postgres.go | 20 ++++--- internal/database/server_repository.go | 10 ++-- internal/database/user_repository.go | 46 +++++++-------- internal/models/modpack.go | 22 +++++++ 9 files changed, 274 insertions(+), 45 deletions(-) create mode 100644 internal/api/modpack_handler.go create mode 100644 internal/core/importer/importer.go create mode 100644 internal/core/importer/simple_zip.go create mode 100644 internal/database/modpack_repository.go create mode 100644 internal/models/modpack.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 525d7e5..3eb8f4f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,13 +16,17 @@ import ( func main() { // Инициализируем соединение с БД - db := database.Connect() - defer db.Close() + dbPool := database.Connect() + defer dbPool.Close() + + userRepo := &database.UserRepository{DB: dbPool} + serverRepo := &database.ServerRepository{DB: dbPool} + modpackRepo := &database.ModpackRepository{DB: dbPool} - userRepo := &database.UserRepository{DB: db} - serverRepo := &database.ServerRepository{DB: db} serverPoller := &core.ServerPoller{Repo: serverRepo} + modpackHandler := &api.ModpackHandler{ModpackRepo: modpackRepo} + // Запускаем поллер в фоновой горутине go serverPoller.Start(context.Background()) @@ -68,11 +72,14 @@ func main() { // --- Защищенные роуты --- r.Group(func(r chi.Router) { - r.Use(api.AuthMiddleware) + r.Use(api.AuthMiddleware) // TODO: Заменить на AdminMiddleware r.Route("/api/user", func(r chi.Router) { r.Post("/skin", profileHandler.UploadSkin) }) + r.Route("/api/admin/modpacks", func(r chi.Router) { + r.Post("/import", modpackHandler.ImportModpack) + }) }) log.Println("Starting backend server on :8080") diff --git a/internal/api/modpack_handler.go b/internal/api/modpack_handler.go new file mode 100644 index 0000000..768cc74 --- /dev/null +++ b/internal/api/modpack_handler.go @@ -0,0 +1,68 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "os" + + "gitea.mrixs.me/minecraft-platform/backend/internal/core/importer" + "gitea.mrixs.me/minecraft-platform/backend/internal/database" + "gitea.mrixs.me/minecraft-platform/backend/internal/models" +) + +type ModpackHandler struct { + ModpackRepo *database.ModpackRepository +} + +func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(512 << 20); err != nil { // 512 MB лимит + http.Error(w, "File too large", http.StatusBadRequest) + return + } + + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "Invalid file upload", http.StatusBadRequest) + return + } + defer file.Close() + + tempFile, err := os.CreateTemp("", "modpack-*.zip") + if err != nil { + http.Error(w, "Could not create temp file", http.StatusInternalServerError) + return + } + defer os.Remove(tempFile.Name()) + defer tempFile.Close() + + _, err = io.Copy(tempFile, file) + if err != nil { + http.Error(w, "Could not save temp file", http.StatusInternalServerError) + return + } + + storagePath := os.Getenv("MODPACKS_STORAGE_PATH") + imp := &importer.SimpleZipImporter{StoragePath: storagePath} + + files, err := imp.Import(tempFile.Name()) + if err != nil { + http.Error(w, fmt.Sprintf("Import failed: %v", err), http.StatusInternalServerError) + return + } + + modpack := &models.Modpack{ + Name: r.FormValue("name"), + DisplayName: r.FormValue("displayName"), + MinecraftVersion: r.FormValue("mcVersion"), + } + + err = h.ModpackRepo.CreateModpackTx(r.Context(), modpack, files) + if err != nil { + http.Error(w, fmt.Sprintf("Database save failed: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, "Modpack '%s' imported successfully with %d files.", modpack.DisplayName, len(files)) +} diff --git a/internal/core/importer/importer.go b/internal/core/importer/importer.go new file mode 100644 index 0000000..3bbfab4 --- /dev/null +++ b/internal/core/importer/importer.go @@ -0,0 +1,9 @@ +package importer + +import "gitea.mrixs.me/minecraft-platform/backend/internal/models" + +// ModpackImporter определяет контракт для всех типов импортеров. +// Он принимает путь к временному zip-файлу и возвращает список файлов модпака. +type ModpackImporter interface { + Import(zipPath string) ([]models.ModpackFile, error) +} diff --git a/internal/core/importer/simple_zip.go b/internal/core/importer/simple_zip.go new file mode 100644 index 0000000..02af221 --- /dev/null +++ b/internal/core/importer/simple_zip.go @@ -0,0 +1,78 @@ +package importer + +import ( + "archive/zip" + "crypto/sha1" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + + "gitea.mrixs.me/minecraft-platform/backend/internal/models" +) + +// SimpleZipImporter реализует интерфейс ModpackImporter для простых zip-архивов. +type SimpleZipImporter struct { + StoragePath string // Путь к хранилищу файлов модпаков +} + +// processFile - это наша "общая конвейерная лента". +// Он читает файл, вычисляет хеш, сохраняет его на диск и возвращает метаданные. +func (i *SimpleZipImporter) processFile(reader io.Reader) (hash string, size int64, err error) { + data, err := io.ReadAll(reader) + if err != nil { + return "", 0, err + } + + hasher := sha1.New() + hasher.Write(data) + hash = hex.EncodeToString(hasher.Sum(nil)) + size = int64(len(data)) + + filePath := filepath.Join(i.StoragePath, hash) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + if err := os.WriteFile(filePath, data, 0644); err != nil { + return "", 0, fmt.Errorf("failed to save file %s: %w", hash, err) + } + } + + return hash, size, nil +} + +// Import реализует основной метод интерфейса. +func (i *SimpleZipImporter) Import(zipPath string) ([]models.ModpackFile, error) { + r, err := zip.OpenReader(zipPath) + if err != nil { + return nil, err + } + defer r.Close() + + var files []models.ModpackFile + + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + + fileReader, err := f.Open() + if err != nil { + return nil, fmt.Errorf("failed to open file in zip %s: %w", f.Name, err) + } + defer fileReader.Close() + + hash, size, err := i.processFile(fileReader) + if err != nil { + return nil, fmt.Errorf("failed to process file %s: %w", f.Name, err) + } + + files = append(files, models.ModpackFile{ + RelativePath: f.Name, + FileHash: hash, + FileSize: size, + }) + } + + return files, nil +} diff --git a/internal/database/modpack_repository.go b/internal/database/modpack_repository.go new file mode 100644 index 0000000..d3790fb --- /dev/null +++ b/internal/database/modpack_repository.go @@ -0,0 +1,49 @@ +// File: backend/internal/database/modpack_repository.go +package database + +import ( + "context" + + "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type ModpackRepository struct { + DB *pgxpool.Pool // <--- ИЗМЕНЕНИЕ +} + +// CreateModpackTx создает модпак и все его файлы в одной транзакции. +func (r *ModpackRepository) CreateModpackTx(ctx context.Context, modpack *models.Modpack, files []models.ModpackFile) error { + tx, err := r.DB.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var modpackID int + err = tx.QueryRow(ctx, + "INSERT INTO modpacks (name, display_name, minecraft_version) VALUES ($1, $2, $3) RETURNING id", + modpack.Name, modpack.DisplayName, modpack.MinecraftVersion, + ).Scan(&modpackID) + if err != nil { + return err + } + + rows := make([][]interface{}, len(files)) + for i, f := range files { + rows[i] = []interface{}{modpackID, f.RelativePath, f.FileHash, f.FileSize, f.DownloadURL} + } + + _, err = tx.CopyFrom( + ctx, + pgx.Identifier{"modpack_files"}, + []string{"modpack_id", "relative_path", "file_hash", "file_size", "download_url"}, + pgx.CopyFromRows(rows), + ) + if err != nil { + return err + } + + return tx.Commit(ctx) +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 967f043..843da68 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -1,29 +1,31 @@ package database import ( - "database/sql" + "context" "log" "os" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5/pgxpool" ) -// Connect устанавливает соединение с базой данных PostgreSQL -func Connect() *sql.DB { +// Connect устанавливает соединение с базой данных PostgreSQL, используя пул соединений pgx. +func Connect() *pgxpool.Pool { connStr := os.Getenv("DATABASE_URL") if connStr == "" { log.Fatal("DATABASE_URL environment variable is not set") } - db, err := sql.Open("pgx", connStr) + // Создаем пул соединений + pool, err := pgxpool.New(context.Background(), connStr) if err != nil { - log.Fatalf("Unable to connect to database: %v\n", err) + log.Fatalf("Unable to create connection pool: %v\n", err) } - if err = db.Ping(); err != nil { + // Проверяем, что соединение действительно установлено + if err = pool.Ping(context.Background()); err != nil { log.Fatalf("Unable to ping database: %v\n", err) } - log.Println("Successfully connected to PostgreSQL!") - return db + log.Println("Successfully connected to PostgreSQL using pgxpool!") + return pool } diff --git a/internal/database/server_repository.go b/internal/database/server_repository.go index 567668f..9f21c05 100644 --- a/internal/database/server_repository.go +++ b/internal/database/server_repository.go @@ -2,18 +2,18 @@ package database import ( "context" - "database/sql" "gitea.mrixs.me/minecraft-platform/backend/internal/models" + "github.com/jackc/pgx/v5/pgxpool" ) type ServerRepository struct { - DB *sql.DB + DB *pgxpool.Pool } // GetAllEnabledServers возвращает все активные серверы для опроса. func (r *ServerRepository) GetAllEnabledServers(ctx context.Context) ([]*models.GameServer, error) { - rows, err := r.DB.QueryContext(ctx, "SELECT id, name, address FROM game_servers WHERE is_enabled = TRUE") + rows, err := r.DB.Query(ctx, "SELECT id, name, address FROM game_servers WHERE is_enabled = TRUE") if err != nil { return nil, err } @@ -37,7 +37,7 @@ func (r *ServerRepository) UpdateServerStatus(ctx context.Context, id int, statu status_json = $1, last_polled_at = NOW(), motd = $2, player_count = $3, max_players = $4, version_name = $5, ping_backend_server = $6 WHERE id = $7` - _, err := r.DB.ExecContext(ctx, query, + _, err := r.DB.Exec(ctx, query, status.StatusJSON, status.Motd, status.PlayerCount, status.MaxPlayers, status.VersionName, status.Ping, id) return err @@ -49,7 +49,7 @@ func (r *ServerRepository) GetAllWithStatus(ctx context.Context) ([]*models.Game SELECT id, name, address, is_enabled, last_polled_at, motd, player_count, max_players, version_name, ping_backend_server FROM game_servers WHERE is_enabled = TRUE ORDER BY name` - rows, err := r.DB.QueryContext(ctx, query) + rows, err := r.DB.Query(ctx, query) if err != nil { return nil, err } diff --git a/internal/database/user_repository.go b/internal/database/user_repository.go index ed6c546..162fc63 100644 --- a/internal/database/user_repository.go +++ b/internal/database/user_repository.go @@ -7,6 +7,8 @@ import ( "gitea.mrixs.me/minecraft-platform/backend/internal/models" "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) var ( @@ -14,21 +16,20 @@ var ( ) type UserRepository struct { - DB *sql.DB + DB *pgxpool.Pool } // CreateUserTx создает нового пользователя и его профиль в рамках одной транзакции func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) error { - tx, err := r.DB.BeginTx(ctx, nil) + tx, err := r.DB.Begin(ctx) if err != nil { return err } - defer tx.Rollback() + defer tx.Rollback(ctx) 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) + err = tx.QueryRow(ctx, + "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)").Scan(&exists) if err != nil { return err } @@ -37,20 +38,17 @@ func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) er } 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) + err = tx.QueryRow(ctx, "INSERT INTO users (uuid, username, email, password_hash, role) VALUES ($1, $2, $3, $4, $5) RETURNING id").Scan(&newUserID) if err != nil { return err } - _, err = tx.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES ($1)", newUserID) + _, err = tx.Exec(ctx, "INSERT INTO profiles (user_id) VALUES ($1)") if err != nil { return err } - return tx.Commit() + return tx.Commit(ctx) } var ( @@ -64,12 +62,12 @@ func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) var userUUID string query := "SELECT id, uuid, username, email, password_hash, role FROM users WHERE username = $1" - err := r.DB.QueryRowContext(ctx, query, username).Scan( + err := r.DB.QueryRow(ctx, query, username).Scan( &user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, err @@ -82,7 +80,7 @@ func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) // CreateAccessToken сохраняет новый токен доступа в базу данных. func (r *UserRepository) CreateAccessToken(ctx context.Context, userID int, accessToken, clientToken string) error { query := "INSERT INTO access_tokens (user_id, access_token, client_token) VALUES ($1, $2, $3)" - _, err := r.DB.ExecContext(ctx, query, userID, accessToken, clientToken) + _, err := r.DB.Exec(ctx, query, userID, accessToken, clientToken) return err } @@ -98,12 +96,12 @@ func (r *UserRepository) GetProfileByUUID(ctx context.Context, userUUID uuid.UUI JOIN profiles p ON u.id = p.user_id WHERE u.uuid = $1` - err := r.DB.QueryRowContext(ctx, query, userUUID).Scan( + err := r.DB.QueryRow(ctx, query, userUUID).Scan( &user.ID, &user.Username, &skinHash, &capeHash, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { return nil, nil, ErrUserNotFound } return nil, nil, err @@ -136,7 +134,7 @@ func (r *UserRepository) ValidateAccessToken(ctx context.Context, token string, WHERE at.access_token = $1 AND u.uuid = $2 )` - err := r.DB.QueryRowContext(ctx, query, token, userUUID).Scan(&exists) + err := r.DB.QueryRow(ctx, query, token, userUUID).Scan(&exists) if err != nil { return err } @@ -151,16 +149,12 @@ func (r *UserRepository) ValidateAccessToken(ctx context.Context, token string, // UpdateSkinHash обновляет хеш скина для пользователя. func (r *UserRepository) UpdateSkinHash(ctx context.Context, userID int, skinHash string) error { query := "UPDATE profiles SET skin_hash = $1, updated_at = NOW() WHERE user_id = $2" - result, err := r.DB.ExecContext(ctx, query, skinHash, userID) + result, err := r.DB.Exec(ctx, query, skinHash, userID) if err != nil { return err } - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - if rowsAffected == 0 { + if result.RowsAffected() == 0 { return ErrUserNotFound } @@ -173,12 +167,12 @@ func (r *UserRepository) GetUserByLogin(ctx context.Context, login string) (*mod var userUUID string 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( + err := r.DB.QueryRow(ctx, query, login).Scan( &user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { return nil, ErrUserNotFound } return nil, err diff --git a/internal/models/modpack.go b/internal/models/modpack.go new file mode 100644 index 0000000..8e33363 --- /dev/null +++ b/internal/models/modpack.go @@ -0,0 +1,22 @@ +package models + +import "time" + +// Modpack представляет запись в таблице 'modpacks' +type Modpack struct { + ID int `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + MinecraftVersion string `json:"minecraft_version"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +// ModpackFile представляет метаданные одного файла в модпаке +type ModpackFile struct { + ModpackID int + RelativePath string + FileHash string + FileSize int64 + DownloadURL string +}