Compare commits
4 Commits
5e609017f0
...
96fb472497
| Author | SHA1 | Date | |
|---|---|---|---|
| 96fb472497 | |||
| 2c682c5123 | |||
| a157fc1cc3 | |||
| ca182d6d6f |
@@ -15,21 +15,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Инициализируем соединение с БД
|
dbPool := database.Connect()
|
||||||
db := database.Connect()
|
defer dbPool.Close()
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
userRepo := &database.UserRepository{DB: db}
|
// --- Инициализация репозиториев ---
|
||||||
serverRepo := &database.ServerRepository{DB: db}
|
userRepo := &database.UserRepository{DB: dbPool}
|
||||||
|
serverRepo := &database.ServerRepository{DB: dbPool}
|
||||||
|
modpackRepo := &database.ModpackRepository{DB: dbPool}
|
||||||
|
|
||||||
|
// --- Инициализация сервисов ---
|
||||||
|
userService := &core.UserService{Repo: userRepo}
|
||||||
|
authService := &core.AuthService{UserRepo: userRepo}
|
||||||
serverPoller := &core.ServerPoller{Repo: serverRepo}
|
serverPoller := &core.ServerPoller{Repo: serverRepo}
|
||||||
|
|
||||||
// Запускаем поллер в фоновой горутине
|
|
||||||
go serverPoller.Start(context.Background())
|
|
||||||
|
|
||||||
// Сервисы
|
|
||||||
userService := &core.UserService{Repo: userRepo}
|
|
||||||
authService := &core.AuthService{UserRepo: userRepo} // Новый сервис
|
|
||||||
// Инициализируем сервис профилей, читая путь к ключу и домен из переменных окружения
|
|
||||||
keyPath := os.Getenv("RSA_PRIVATE_KEY_PATH")
|
keyPath := os.Getenv("RSA_PRIVATE_KEY_PATH")
|
||||||
if keyPath == "" {
|
if keyPath == "" {
|
||||||
log.Fatal("RSA_PRIVATE_KEY_PATH environment variable is not set")
|
log.Fatal("RSA_PRIVATE_KEY_PATH environment variable is not set")
|
||||||
@@ -42,12 +40,29 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create profile service: %v", err)
|
log.Fatalf("Failed to create profile service: %v", err)
|
||||||
}
|
}
|
||||||
// Хендлеры
|
|
||||||
|
modpacksStoragePath := os.Getenv("MODPACKS_STORAGE_PATH")
|
||||||
|
if modpacksStoragePath == "" {
|
||||||
|
log.Fatal("MODPACKS_STORAGE_PATH environment variable is not set")
|
||||||
|
}
|
||||||
|
janitorService := core.NewFileJanitorService(modpackRepo, modpacksStoragePath)
|
||||||
|
|
||||||
|
// --- Запуск фоновых задач ---
|
||||||
|
go serverPoller.Start(context.Background())
|
||||||
|
|
||||||
|
// --- Инициализация хендлеров ---
|
||||||
userHandler := &api.UserHandler{Service: userService}
|
userHandler := &api.UserHandler{Service: userService}
|
||||||
authHandler := &api.AuthHandler{Service: authService}
|
authHandler := &api.AuthHandler{Service: authService}
|
||||||
profileHandler := &api.ProfileHandler{Service: profileService}
|
profileHandler := &api.ProfileHandler{Service: profileService}
|
||||||
serverHandler := &api.ServerHandler{Repo: serverRepo}
|
serverHandler := &api.ServerHandler{Repo: serverRepo}
|
||||||
// --- Настраиваем роутер ---
|
launcherHandler := &api.LauncherHandler{ModpackRepo: modpackRepo}
|
||||||
|
modpackHandler := &api.ModpackHandler{
|
||||||
|
ModpackRepo: modpackRepo,
|
||||||
|
JanitorService: janitorService,
|
||||||
|
}
|
||||||
|
adminUserHandler := &api.AdminUserHandler{UserRepo: userRepo} // Этот хендлер мы создали для админских функций
|
||||||
|
|
||||||
|
// --- Настройка роутера ---
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -57,6 +72,10 @@ func main() {
|
|||||||
r.Post("/register", userHandler.Register)
|
r.Post("/register", userHandler.Register)
|
||||||
r.Post("/login", authHandler.Login)
|
r.Post("/login", authHandler.Login)
|
||||||
r.Get("/servers", serverHandler.GetServers)
|
r.Get("/servers", serverHandler.GetServers)
|
||||||
|
|
||||||
|
r.Route("/launcher", func(r chi.Router) {
|
||||||
|
r.Get("/modpacks/{name}/manifest", launcherHandler.GetModpackManifest)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
r.Route("/authserver", func(r chi.Router) {
|
r.Route("/authserver", func(r chi.Router) {
|
||||||
r.Post("/authenticate", authHandler.Authenticate)
|
r.Post("/authenticate", authHandler.Authenticate)
|
||||||
@@ -73,6 +92,20 @@ func main() {
|
|||||||
r.Route("/api/user", func(r chi.Router) {
|
r.Route("/api/user", func(r chi.Router) {
|
||||||
r.Post("/skin", profileHandler.UploadSkin)
|
r.Post("/skin", profileHandler.UploadSkin)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/api/admin", func(r chi.Router) {
|
||||||
|
r.Use(api.AdminMiddleware)
|
||||||
|
|
||||||
|
r.Route("/modpacks", func(r chi.Router) {
|
||||||
|
r.Post("/import", modpackHandler.ImportModpack)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/users", func(r chi.Router) {
|
||||||
|
// ИСПРАВЛЕНО: Используем adminUserHandler
|
||||||
|
r.Get("/", adminUserHandler.GetAllUsers)
|
||||||
|
r.Patch("/{id}/role", adminUserHandler.UpdateUserRole)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
log.Println("Starting backend server on :8080")
|
log.Println("Starting backend server on :8080")
|
||||||
|
|||||||
40
internal/api/admin_user_handler.go
Normal file
40
internal/api/admin_user_handler.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdminUserHandler struct {
|
||||||
|
UserRepo *database.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminUserHandler) GetAllUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := h.UserRepo.GetAllUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to get users", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminUserHandler) UpdateUserRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := strconv.Atoi(chi.URLParam(r, "id"))
|
||||||
|
var payload struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: Валидация роли
|
||||||
|
if err := h.UserRepo.UpdateUserRole(r.Context(), userID, payload.Role); err != nil {
|
||||||
|
http.Error(w, "Failed to update role", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
37
internal/api/launcher_handler.go
Normal file
37
internal/api/launcher_handler.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LauncherHandler struct {
|
||||||
|
ModpackRepo *database.ModpackRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LauncherHandler) GetModpackManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
modpackName := chi.URLParam(r, "name")
|
||||||
|
if modpackName == "" {
|
||||||
|
http.Error(w, "Modpack name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := h.ModpackRepo.GetModpackManifest(r.Context(), modpackName)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
http.Error(w, "Modpack not found or not active", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Failed to get modpack manifest", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(manifest)
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
|
|
||||||
const UserIDContextKey = contextKey("userID")
|
const ClaimsContextKey = contextKey("claims")
|
||||||
|
|
||||||
// AuthMiddleware проверяет JWT токен и добавляет user_id в контекст запроса.
|
// AuthMiddleware проверяет JWT токен и добавляет claims в контекст запроса.
|
||||||
func AuthMiddleware(next http.Handler) http.Handler {
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
@@ -52,14 +52,28 @@ func AuthMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDFloat, ok := claims["user_id"].(float64)
|
// Добавляем claims в контекст
|
||||||
if !ok {
|
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
||||||
http.Error(w, "Invalid user_id in token", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := int(userIDFloat)
|
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserIDContextKey, userID)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminMiddleware проверяет, что пользователь аутентифицирован и имеет роль 'admin'.
|
||||||
|
func AdminMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Получаем claims из контекста
|
||||||
|
claims, ok := r.Context().Value(ClaimsContextKey).(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Could not get claims from context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role, ok := claims["role"].(string)
|
||||||
|
if !ok || role != "admin" {
|
||||||
|
http.Error(w, "Forbidden: insufficient permissions", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
75
internal/api/modpack_handler.go
Normal file
75
internal/api/modpack_handler.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
||||||
|
"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
|
||||||
|
JanitorService *core.FileJanitorService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportModpack обрабатывает загрузку и импорт модпака.
|
||||||
|
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))
|
||||||
|
|
||||||
|
// Запускаем очистку в фоне, чтобы не блокировать ответ
|
||||||
|
go h.JanitorService.CleanOrphanedFiles(context.Background())
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
||||||
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,11 +40,18 @@ func (h *ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *ProfileHandler) UploadSkin(w http.ResponseWriter, r *http.Request) {
|
func (h *ProfileHandler) UploadSkin(w http.ResponseWriter, r *http.Request) {
|
||||||
userID, ok := r.Context().Value(UserIDContextKey).(int)
|
// Получаем claims из контекста
|
||||||
|
claims, ok := r.Context().Value(ClaimsContextKey).(jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "Could not get user ID from context", http.StatusInternalServerError)
|
http.Error(w, "Could not get claims from context", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userIDFloat, ok := claims["user_id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid user_id in token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID := int(userIDFloat)
|
||||||
|
|
||||||
r.ParseMultipartForm(256 << 10) // 256KB
|
r.ParseMultipartForm(256 << 10) // 256KB
|
||||||
|
|
||||||
|
|||||||
61
internal/core/file_janitor.go
Normal file
61
internal/core/file_janitor.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileJanitorService отвечает за очистку осиротевших файлов.
|
||||||
|
type FileJanitorService struct {
|
||||||
|
ModpackRepo *database.ModpackRepository
|
||||||
|
StoragePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileJanitorService создает новый экземпляр сервиса-уборщика.
|
||||||
|
func NewFileJanitorService(repo *database.ModpackRepository, storagePath string) *FileJanitorService {
|
||||||
|
return &FileJanitorService{
|
||||||
|
ModpackRepo: repo,
|
||||||
|
StoragePath: storagePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanOrphanedFiles сканирует хранилище и удаляет файлы, отсутствующие в БД.
|
||||||
|
func (s *FileJanitorService) CleanOrphanedFiles(ctx context.Context) {
|
||||||
|
log.Println("Janitor: Starting orphaned file cleanup...")
|
||||||
|
|
||||||
|
liveHashes, err := s.ModpackRepo.GetAllFileHashes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Janitor: Error getting live hashes from DB: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
diskFiles, err := os.ReadDir(s.StoragePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Janitor: Error reading storage directory %s: %v", s.StoragePath, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removedCount := 0
|
||||||
|
for _, file := range diskFiles {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := file.Name()
|
||||||
|
if _, isLive := liveHashes[fileName]; !isLive {
|
||||||
|
filePath := filepath.Join(s.StoragePath, fileName)
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
log.Printf("Janitor: Failed to remove orphaned file %s: %v", filePath, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Janitor: Removed orphaned file %s", filePath)
|
||||||
|
removedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Janitor: Cleanup complete. Removed %d orphaned files.", removedCount)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
103
internal/database/modpack_repository.go
Normal file
103
internal/database/modpack_repository.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModpackManifest возвращает список файлов для указанного модпака.
|
||||||
|
func (r *ModpackRepository) GetModpackManifest(ctx context.Context, modpackName string) ([]models.ManifestEntry, error) {
|
||||||
|
query := `
|
||||||
|
SELECT mf.relative_path, mf.file_hash, mf.file_size
|
||||||
|
FROM modpack_files mf
|
||||||
|
JOIN modpacks m ON mf.modpack_id = m.id
|
||||||
|
WHERE m.name = $1 AND m.is_active = TRUE`
|
||||||
|
|
||||||
|
rows, err := r.DB.Query(ctx, query, modpackName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var manifest []models.ManifestEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry models.ManifestEntry
|
||||||
|
if err := rows.Scan(&entry.Path, &entry.Hash, &entry.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
manifest = append(manifest, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(manifest) == 0 {
|
||||||
|
var exists bool
|
||||||
|
err := r.DB.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM modpacks WHERE name = $1)", modpackName).Scan(&exists)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return nil, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllFileHashes извлекает все уникальные хеши файлов из базы данных.
|
||||||
|
func (r *ModpackRepository) GetAllFileHashes(ctx context.Context) (map[string]struct{}, error) {
|
||||||
|
query := "SELECT DISTINCT file_hash FROM modpack_files"
|
||||||
|
rows, err := r.DB.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
hashes := make(map[string]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var hash string
|
||||||
|
if err := rows.Scan(&hash); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hashes[hash] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashes, rows.Err()
|
||||||
|
}
|
||||||
@@ -1,29 +1,31 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connect устанавливает соединение с базой данных PostgreSQL
|
// Connect устанавливает соединение с базой данных PostgreSQL, используя пул соединений pgx.
|
||||||
func Connect() *sql.DB {
|
func Connect() *pgxpool.Pool {
|
||||||
connStr := os.Getenv("DATABASE_URL")
|
connStr := os.Getenv("DATABASE_URL")
|
||||||
if connStr == "" {
|
if connStr == "" {
|
||||||
log.Fatal("DATABASE_URL environment variable is not set")
|
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 {
|
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.Fatalf("Unable to ping database: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Successfully connected to PostgreSQL!")
|
log.Println("Successfully connected to PostgreSQL using pgxpool!")
|
||||||
return db
|
return pool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerRepository struct {
|
type ServerRepository struct {
|
||||||
DB *sql.DB
|
DB *pgxpool.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllEnabledServers возвращает все активные серверы для опроса.
|
// GetAllEnabledServers возвращает все активные серверы для опроса.
|
||||||
func (r *ServerRepository) GetAllEnabledServers(ctx context.Context) ([]*models.GameServer, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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,
|
status_json = $1, last_polled_at = NOW(), motd = $2, player_count = $3,
|
||||||
max_players = $4, version_name = $5, ping_backend_server = $6
|
max_players = $4, version_name = $5, ping_backend_server = $6
|
||||||
WHERE id = $7`
|
WHERE id = $7`
|
||||||
_, err := r.DB.ExecContext(ctx, query,
|
_, err := r.DB.Exec(ctx, query,
|
||||||
status.StatusJSON, status.Motd, status.PlayerCount, status.MaxPlayers,
|
status.StatusJSON, status.Motd, status.PlayerCount, status.MaxPlayers,
|
||||||
status.VersionName, status.Ping, id)
|
status.VersionName, status.Ping, id)
|
||||||
return err
|
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,
|
SELECT id, name, address, is_enabled, last_polled_at, motd,
|
||||||
player_count, max_players, version_name, ping_backend_server
|
player_count, max_players, version_name, ping_backend_server
|
||||||
FROM game_servers WHERE is_enabled = TRUE ORDER BY name`
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
|
|
||||||
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -14,21 +16,20 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserRepository struct {
|
type UserRepository struct {
|
||||||
DB *sql.DB
|
DB *pgxpool.Pool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUserTx создает нового пользователя и его профиль в рамках одной транзакции
|
// CreateUserTx создает нового пользователя и его профиль в рамках одной транзакции
|
||||||
func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
var exists bool
|
var exists bool
|
||||||
err = tx.QueryRowContext(ctx,
|
err = tx.QueryRow(ctx,
|
||||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)",
|
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)").Scan(&exists)
|
||||||
user.Username, user.Email).Scan(&exists)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -37,20 +38,17 @@ func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
var newUserID int
|
var newUserID int
|
||||||
err = tx.QueryRowContext(ctx,
|
err = tx.QueryRow(ctx, "INSERT INTO users (uuid, username, email, password_hash, role) VALUES ($1, $2, $3, $4, $5) RETURNING id").Scan(&newUserID)
|
||||||
"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 {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.Commit()
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -64,12 +62,12 @@ func (r *UserRepository) GetUserByUsername(ctx context.Context, username string)
|
|||||||
var userUUID string
|
var userUUID string
|
||||||
|
|
||||||
query := "SELECT id, uuid, username, email, password_hash, role FROM users WHERE username = $1"
|
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,
|
&user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -82,7 +80,7 @@ func (r *UserRepository) GetUserByUsername(ctx context.Context, username string)
|
|||||||
// CreateAccessToken сохраняет новый токен доступа в базу данных.
|
// CreateAccessToken сохраняет новый токен доступа в базу данных.
|
||||||
func (r *UserRepository) CreateAccessToken(ctx context.Context, userID int, accessToken, clientToken string) error {
|
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)"
|
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
|
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
|
JOIN profiles p ON u.id = p.user_id
|
||||||
WHERE u.uuid = $1`
|
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,
|
&user.ID, &user.Username, &skinHash, &capeHash,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil, ErrUserNotFound
|
return nil, nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
return nil, nil, err
|
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
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -151,16 +149,12 @@ func (r *UserRepository) ValidateAccessToken(ctx context.Context, token string,
|
|||||||
// UpdateSkinHash обновляет хеш скина для пользователя.
|
// UpdateSkinHash обновляет хеш скина для пользователя.
|
||||||
func (r *UserRepository) UpdateSkinHash(ctx context.Context, userID int, skinHash string) error {
|
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"
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
if result.RowsAffected() == 0 {
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if rowsAffected == 0 {
|
|
||||||
return ErrUserNotFound
|
return ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,12 +167,12 @@ func (r *UserRepository) GetUserByLogin(ctx context.Context, login string) (*mod
|
|||||||
var userUUID string
|
var userUUID string
|
||||||
|
|
||||||
query := "SELECT id, uuid, username, email, password_hash, role, created_at, updated_at FROM users WHERE username = $1 OR email = $1"
|
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,
|
&user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, ErrUserNotFound
|
return nil, ErrUserNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -187,3 +181,36 @@ func (r *UserRepository) GetUserByLogin(ctx context.Context, login string) (*mod
|
|||||||
user.UUID, _ = uuid.Parse(userUUID)
|
user.UUID, _ = uuid.Parse(userUUID)
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllUsers возвращает список всех пользователей.
|
||||||
|
func (r *UserRepository) GetAllUsers(ctx context.Context) ([]models.User, error) {
|
||||||
|
rows, err := r.DB.Query(ctx, "SELECT id, uuid, username, email, role, created_at, updated_at FROM users ORDER BY id")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []models.User
|
||||||
|
for rows.Next() {
|
||||||
|
var u models.User
|
||||||
|
var userUUID string
|
||||||
|
if err := rows.Scan(&u.ID, &userUUID, &u.Username, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.UUID, _ = uuid.Parse(userUUID)
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRole обновляет роль пользователя по его ID.
|
||||||
|
func (r *UserRepository) UpdateUserRole(ctx context.Context, userID int, newRole string) error {
|
||||||
|
res, err := r.DB.Exec(ctx, "UPDATE users SET role = $1 WHERE id = $2", newRole, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.RowsAffected() == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
8
internal/models/launcher.go
Normal file
8
internal/models/launcher.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// ManifestEntry представляет один файл в манифесте модпака.
|
||||||
|
type ManifestEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
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