feat(modpacks): implement simple zip importer and API
This commit is contained in:
@@ -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")
|
||||
|
||||
68
internal/api/modpack_handler.go
Normal file
68
internal/api/modpack_handler.go
Normal file
@@ -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))
|
||||
}
|
||||
9
internal/core/importer/importer.go
Normal file
9
internal/core/importer/importer.go
Normal file
@@ -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)
|
||||
}
|
||||
78
internal/core/importer/simple_zip.go
Normal file
78
internal/core/importer/simple_zip.go
Normal file
@@ -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
|
||||
}
|
||||
49
internal/database/modpack_repository.go
Normal file
49
internal/database/modpack_repository.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
22
internal/models/modpack.go
Normal file
22
internal/models/modpack.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user