Compare commits
38 Commits
795f220e90
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e022e966 | |||
| 2f6d08d7c4 | |||
| ec0a9efeb4 | |||
| 72875a6256 | |||
| 463b04bff0 | |||
| 3fee913c1c | |||
| 0e5d98cff7 | |||
| 2dcb1e7735 | |||
| 1cdfe9fefc | |||
| 9e2657c709 | |||
| 9bf2a15045 | |||
| 0751ddb88a | |||
| 192ec80010 | |||
| 275c1f2d50 | |||
| e98d10ae1d | |||
| 58aa72f9bb | |||
| 119863b816 | |||
| 3b74bf16ad | |||
| c282330661 | |||
| b9c5a7f739 | |||
| 29e85570f8 | |||
| 22d54dea63 | |||
| ac9db69585 | |||
| 96fb472497 | |||
| 2c682c5123 | |||
| a157fc1cc3 | |||
| ca182d6d6f | |||
| 5e609017f0 | |||
| 42f2b68848 | |||
| 0e2e02622d | |||
| 5cd84d4f7b | |||
| 69db68607f | |||
| 45173c406c | |||
| 9c7940a70a | |||
| 9082b21a5d | |||
| 056aa05c50 | |||
| 4d42cfff2d | |||
| 54ce479a6e |
@@ -1,6 +1,6 @@
|
|||||||
# --- Этап 1: Сборка (Builder) ---
|
# --- Этап 1: Сборка (Builder) ---
|
||||||
# Используем официальный образ Go в качестве сборочной среды
|
# Используем официальный образ Go в качестве сборочной среды
|
||||||
FROM golang:1.22-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
# Устанавливаем рабочую директорию внутри контейнера
|
# Устанавливаем рабочую директорию внутри контейнера
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -8,6 +8,8 @@ WORKDIR /app
|
|||||||
# Копируем файлы go.mod и go.sum для загрузки зависимостей
|
# Копируем файлы go.mod и go.sum для загрузки зависимостей
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
# Загружаем зависимости. Этот слой будет кэшироваться, если файлы не менялись
|
# Загружаем зависимости. Этот слой будет кэшироваться, если файлы не менялись
|
||||||
|
ENV GOPROXY=direct
|
||||||
|
RUN apk add --no-cache git
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Копируем весь остальной исходный код
|
# Копируем весь остальной исходный код
|
||||||
|
|||||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Бэкенд-сервер (Go)
|
||||||
|
|
||||||
|
Этот сервис является ядром платформы. Он написан на Go и использует:
|
||||||
|
- **Роутер:** `go-chi/chi`
|
||||||
|
- **База данных:** PostgreSQL (с драйвером `pgx`)
|
||||||
|
- **Аутентификация:** `bcrypt` для паролей, `JWT` для сессий.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Реализует три основных типа API:
|
||||||
|
1. **Yggdrasil API** (`/authserver`, `/sessionserver`): для совместимости с лаунчером и игровыми серверами.
|
||||||
|
2. **Web API** (`/api`): для взаимодействия с фронтендом.
|
||||||
|
3. **Admin API** (`/api/admin`): для административных задач.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
Сервер запускается командой `go run cmd/server/main.go` и слушает порт `8080`.
|
||||||
@@ -1,29 +1,193 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/api"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/ws"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Создаем новый роутер
|
// --- Инициализация логгера (slog) ---
|
||||||
r := chi.NewRouter()
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
// Используем стандартные middleware для логирования и восстановления после паник
|
slog.Info("Starting backend server initialization...")
|
||||||
r.Use(middleware.Logger)
|
|
||||||
|
dbPool := database.Connect()
|
||||||
|
defer dbPool.Close()
|
||||||
|
|
||||||
|
// --- Инициализация репозиториев ---
|
||||||
|
userRepo := &database.UserRepository{DB: dbPool}
|
||||||
|
serverRepo := &database.ServerRepository{DB: dbPool}
|
||||||
|
modpackRepo := &database.ModpackRepository{DB: dbPool}
|
||||||
|
jobRepo := &database.JobRepository{DB: dbPool}
|
||||||
|
|
||||||
|
// --- Инициализация сервисов ---
|
||||||
|
userService := &core.UserService{Repo: userRepo}
|
||||||
|
authService := &core.AuthService{UserRepo: userRepo}
|
||||||
|
serverPoller := &core.ServerPoller{Repo: serverRepo}
|
||||||
|
|
||||||
|
// --- Инициализация WebSocket Hub ---
|
||||||
|
hub := ws.NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
keyPath := os.Getenv("RSA_PRIVATE_KEY_PATH")
|
||||||
|
if keyPath == "" {
|
||||||
|
slog.Error("RSA_PRIVATE_KEY_PATH environment variable is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
domain := os.Getenv("APP_DOMAIN")
|
||||||
|
if domain == "" {
|
||||||
|
slog.Error("APP_DOMAIN environment variable is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
profileService, err := core.NewProfileService(userRepo, keyPath, domain)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to create profile service", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
modpacksStoragePath := os.Getenv("MODPACKS_STORAGE_PATH")
|
||||||
|
if modpacksStoragePath == "" {
|
||||||
|
slog.Error("MODPACKS_STORAGE_PATH environment variable is not set")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
janitorService := core.NewFileJanitorService(modpackRepo, modpacksStoragePath)
|
||||||
|
|
||||||
|
// --- Запуск фоновых задач ---
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go serverPoller.Start(ctx) // Передаем контекст для отмены
|
||||||
|
|
||||||
|
// --- Инициализация хендлеров ---
|
||||||
|
userHandler := &api.UserHandler{Service: userService}
|
||||||
|
authHandler := &api.AuthHandler{Service: authService}
|
||||||
|
profileHandler := &api.ProfileHandler{Service: profileService}
|
||||||
|
serverHandler := &api.ServerHandler{Repo: serverRepo}
|
||||||
|
launcherHandler := &api.LauncherHandler{ModpackRepo: modpackRepo}
|
||||||
|
modpackHandler := &api.ModpackHandler{
|
||||||
|
ModpackRepo: modpackRepo,
|
||||||
|
JobRepo: jobRepo,
|
||||||
|
JanitorService: janitorService,
|
||||||
|
Hub: hub,
|
||||||
|
}
|
||||||
|
adminUserHandler := &api.AdminUserHandler{UserRepo: userRepo}
|
||||||
|
|
||||||
|
// --- Настройка роутера ---
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(middleware.Logger) // Можно заменить на slog middleware, но пока оставим standard
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
// Определяем простой маршрут
|
// Health Check
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Write([]byte("Backend server is running!"))
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK"))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Запускаем сервер на порту 8080, как мы указали в Caddyfile
|
// --- Публичные роуты ---
|
||||||
log.Println("Starting backend server on :8080")
|
r.Route("/api", func(r chi.Router) {
|
||||||
if err := http.ListenAndServe(":8080", r); err != nil {
|
// Rate limiting: 100 requests per minute for general API
|
||||||
log.Fatalf("Failed to start server: %v", err)
|
r.Use(httprate.LimitByIP(100, time.Minute))
|
||||||
|
|
||||||
|
// Auth endpoints: stricter limit (10 requests per minute)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(httprate.LimitByIP(10, time.Minute))
|
||||||
|
r.Post("/register", userHandler.Register)
|
||||||
|
r.Post("/login", authHandler.Login)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/servers", serverHandler.GetServers)
|
||||||
|
|
||||||
|
r.Route("/launcher", func(r chi.Router) {
|
||||||
|
r.Get("/modpacks/{name}/manifest", launcherHandler.GetModpackManifest)
|
||||||
|
r.Get("/modpacks/summary", launcherHandler.GetModpacksSummary)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.Route("/authserver", func(r chi.Router) {
|
||||||
|
// Stricter rate limit for auth server (10 req/min)
|
||||||
|
r.Use(httprate.LimitByIP(10, time.Minute))
|
||||||
|
r.Post("/authenticate", authHandler.Authenticate)
|
||||||
|
})
|
||||||
|
r.Route("/sessionserver/session/minecraft", func(r chi.Router) {
|
||||||
|
// Rate limit for session endpoints (60 req/min)
|
||||||
|
r.Use(httprate.LimitByIP(60, time.Minute))
|
||||||
|
r.Post("/join", authHandler.Join)
|
||||||
|
r.Get("/profile/{uuid}", profileHandler.GetProfile)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Защищенные роуты ---
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(api.AuthMiddleware)
|
||||||
|
|
||||||
|
r.Route("/api/user", func(r chi.Router) {
|
||||||
|
r.Post("/skin", profileHandler.UploadSkin)
|
||||||
|
r.Get("/me", userHandler.GetMe)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/api/admin", func(r chi.Router) {
|
||||||
|
r.Use(api.AdminMiddleware)
|
||||||
|
|
||||||
|
// WebSocket endpoint for jobs
|
||||||
|
r.Get("/ws/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ws.ServeWs(hub, w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/modpacks", func(r chi.Router) {
|
||||||
|
r.Get("/", modpackHandler.GetModpacks)
|
||||||
|
r.Post("/import", modpackHandler.ImportModpack)
|
||||||
|
r.Post("/update", modpackHandler.UpdateModpack)
|
||||||
|
r.Get("/versions", modpackHandler.GetModpackVersions)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/users", func(r chi.Router) {
|
||||||
|
r.Get("/", adminUserHandler.GetAllUsers)
|
||||||
|
r.Patch("/{id}/role", adminUserHandler.UpdateUserRole)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Graceful Shutdown ---
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":8080",
|
||||||
|
Handler: r,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("Starting backend server on :8080")
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("Failed to start server", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for interrupt signal to gracefully shutdown the server with a timeout.
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
slog.Info("Shutting down server...")
|
||||||
|
|
||||||
|
// The context is used to inform the server it has 5 seconds to finish
|
||||||
|
// the request it is currently handling
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
slog.Error("Server forced to shutdown", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Server exiting")
|
||||||
}
|
}
|
||||||
|
|||||||
27
go.mod
27
go.mod
@@ -2,4 +2,29 @@ module gitea.mrixs.me/minecraft-platform/backend
|
|||||||
|
|
||||||
go 1.24.1
|
go 1.24.1
|
||||||
|
|
||||||
require github.com/go-chi/chi/v5 v5.2.1 // indirect
|
require (
|
||||||
|
github.com/Tnze/go-mc v1.20.2
|
||||||
|
github.com/go-chi/chi/v5 v5.2.1
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/jackc/pgx/v5 v5.7.5
|
||||||
|
golang.org/x/crypto v0.46.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/go-chi/httprate v0.15.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
56
go.sum
56
go.sum
@@ -1,2 +1,58 @@
|
|||||||
|
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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
|
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/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
|
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
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/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
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.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||||
|
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
|
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=
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
128
internal/api/auth_handler.go
Normal file
128
internal/api/auth_handler.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
Service *core.AuthService
|
||||||
|
}
|
||||||
|
|
||||||
|
// YggdrasilError - стандартный формат ошибки для authserver
|
||||||
|
type YggdrasilError struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Authenticate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.AuthenticateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if validationErrors := utils.ValidateStruct(req); validationErrors != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(validationErrors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := h.Service.Authenticate(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, core.ErrInvalidCredentials) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden) // 403
|
||||||
|
json.NewEncoder(w).Encode(YggdrasilError{
|
||||||
|
Error: "ForbiddenOperationException",
|
||||||
|
ErrorMessage: "Invalid credentials. Invalid username or password.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Другие ошибки - внутренние
|
||||||
|
slog.Error("internal server error during authentication", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.JoinRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if validationErrors := utils.ValidateStruct(req); validationErrors != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(validationErrors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.Service.ValidateJoinRequest(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, core.ErrInvalidCredentials) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
json.NewEncoder(w).Encode(YggdrasilError{
|
||||||
|
Error: "ForbiddenOperationException",
|
||||||
|
ErrorMessage: "Invalid token.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("internal server error during join", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if validationErrors := utils.ValidateStruct(req); validationErrors != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(validationErrors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, user, err := h.Service.LoginUser(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, core.ErrInvalidCredentials) {
|
||||||
|
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("internal server error during login", "error", err)
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := models.LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
User: user,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
49
internal/api/launcher_handler.go
Normal file
49
internal/api/launcher_handler.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LauncherHandler) GetModpacksSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
summaries, err := h.ModpackRepo.GetModpacksSummary(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to get modpacks summary", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(summaries)
|
||||||
|
}
|
||||||
89
internal/api/middleware.go
Normal file
89
internal/api/middleware.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const ClaimsContextKey = contextKey("claims")
|
||||||
|
|
||||||
|
// AuthMiddleware проверяет JWT токен и добавляет claims в контекст запроса.
|
||||||
|
func AuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var tokenString string
|
||||||
|
|
||||||
|
// 1. Проверяем заголовок Authorization
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader != "" {
|
||||||
|
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if tokenString == authHeader { // Не было префикса Bearer
|
||||||
|
http.Error(w, "Invalid token format", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если заголовка нет, проверяем параметр query (для WebSocket)
|
||||||
|
if tokenString == "" {
|
||||||
|
tokenString = r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenString == "" {
|
||||||
|
http.Error(w, "Authorization required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtSecret := []byte(os.Getenv("JWT_SECRET_KEY"))
|
||||||
|
if len(jwtSecret) == 0 {
|
||||||
|
http.Error(w, "JWT secret not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем claims в контекст
|
||||||
|
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
460
internal/api/modpack_handler.go
Normal file
460
internal/api/modpack_handler.go
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModpackHandler struct {
|
||||||
|
ModpackRepo *database.ModpackRepository
|
||||||
|
JobRepo *database.JobRepository
|
||||||
|
JanitorService *core.FileJanitorService
|
||||||
|
Hub *ws.Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportJobParams содержит параметры для фоновой задачи импорта
|
||||||
|
type ImportJobParams struct {
|
||||||
|
ImporterType string
|
||||||
|
ImportMethod string
|
||||||
|
SourceURL string
|
||||||
|
TempZipPath string
|
||||||
|
Name string
|
||||||
|
DisplayName string
|
||||||
|
MCVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportModpack обрабатывает запрос на импорт и запускает асинхронную задачу
|
||||||
|
func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(512 << 20); err != nil {
|
||||||
|
http.Error(w, "File too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := ImportJobParams{
|
||||||
|
ImporterType: r.FormValue("importerType"),
|
||||||
|
ImportMethod: r.FormValue("importMethod"),
|
||||||
|
SourceURL: r.FormValue("sourceUrl"),
|
||||||
|
Name: r.FormValue("name"),
|
||||||
|
DisplayName: r.FormValue("displayName"),
|
||||||
|
MCVersion: r.FormValue("mcVersion"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if params.Name == "" || params.DisplayName == "" || params.MCVersion == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка загрузки файла
|
||||||
|
if params.ImportMethod == "file" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Не удаляем файл здесь, так как он нужен worker-у. Удалим в worker-е.
|
||||||
|
// defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
if _, err := io.Copy(tempFile, file); err != nil {
|
||||||
|
tempFile.Close()
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempFile.Close()
|
||||||
|
params.TempZipPath = tempFile.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем задачу в БД
|
||||||
|
jobID, err := h.JobRepo.CreateJob(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
if params.ImportMethod == "file" {
|
||||||
|
os.Remove(params.TempZipPath)
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create job: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем worker
|
||||||
|
go h.processImportJob(jobID, params)
|
||||||
|
|
||||||
|
// Отправляем ответ
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"message": "Import job started",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// processImportJob выполняет импорт в фоне
|
||||||
|
func (h *ModpackHandler) processImportJob(jobID int, params ImportJobParams) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 10, "")
|
||||||
|
|
||||||
|
// Очистка временного файла по завершении
|
||||||
|
if params.TempZipPath != "" {
|
||||||
|
defer os.Remove(params.TempZipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
|
||||||
|
var imp importer.ModpackImporter
|
||||||
|
|
||||||
|
// Настройка импортера
|
||||||
|
switch params.ImporterType {
|
||||||
|
case "simple":
|
||||||
|
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
|
||||||
|
case "curseforge":
|
||||||
|
apiKey := os.Getenv("CURSEFORGE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "CurseForge API key missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
|
||||||
|
case "modrinth":
|
||||||
|
imp = importer.NewModrinthImporter(storagePath)
|
||||||
|
default:
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "Invalid importer type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка по URL если нужно
|
||||||
|
if params.ImportMethod == "url" {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 20, "Downloading from URL...")
|
||||||
|
|
||||||
|
// Логика скачивания зависит от типа импортера, пока поддерживаем CurseForge
|
||||||
|
// TODO: Сделать интерфейс DownloadableImporter
|
||||||
|
if cfImporter, ok := imp.(*importer.CurseForgeImporter); ok {
|
||||||
|
zipPath, err := cfImporter.DownloadModpackFromURL(params.SourceURL)
|
||||||
|
if err != nil {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Download failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.TempZipPath = zipPath
|
||||||
|
defer os.Remove(zipPath) // Удаляем скачанный файл после обработки
|
||||||
|
} else {
|
||||||
|
// Для других типов пока не поддерживаем URL download внутри импортера
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "URL import not supported for this type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 40, "Processing modpack files...")
|
||||||
|
|
||||||
|
// Импорт файлов
|
||||||
|
files, err := imp.Import(params.TempZipPath)
|
||||||
|
if err != nil {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Import failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 80, "Saving to database...")
|
||||||
|
|
||||||
|
// Сохранение в БД
|
||||||
|
modpack := &models.Modpack{
|
||||||
|
Name: params.Name,
|
||||||
|
DisplayName: params.DisplayName,
|
||||||
|
MinecraftVersion: params.MCVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.ModpackRepo.CreateModpackTx(ctx, modpack, files)
|
||||||
|
if err != nil {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Database save failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusCompleted, 100, "Success")
|
||||||
|
|
||||||
|
// Запуск Janitor-а
|
||||||
|
go h.JanitorService.CleanOrphanedFiles(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateJobStatus обновляет статус в БД и отправляет уведомление через WebSocket
|
||||||
|
func (h *ModpackHandler) updateJobStatus(ctx context.Context, jobID int, status models.ImportJobStatus, progress int, errMsg string) {
|
||||||
|
// Обновляем БД
|
||||||
|
if err := h.JobRepo.UpdateJobStatus(ctx, jobID, status, progress, errMsg); err != nil {
|
||||||
|
slog.Error("Failed to update job status in DB", "jobID", jobID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем в WebSocket
|
||||||
|
update := map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"status": status,
|
||||||
|
"progress": progress,
|
||||||
|
"error_message": errMsg,
|
||||||
|
}
|
||||||
|
msg, _ := json.Marshal(update)
|
||||||
|
h.Hub.BroadcastMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModpackVersionResponse представляет версию модпака для ответа API
|
||||||
|
type ModpackVersionResponse struct {
|
||||||
|
FileID int `json:"file_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
FileDate string `json:"file_date"`
|
||||||
|
GameVersions []string `json:"game_versions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModpackVersions возвращает список доступных версий для модпака по URL
|
||||||
|
func (h *ModpackHandler) GetModpackVersions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageURL := r.URL.Query().Get("url")
|
||||||
|
if pageURL == "" {
|
||||||
|
http.Error(w, "Missing 'url' query parameter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := os.Getenv("CURSEFORGE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
http.Error(w, "CurseForge API key not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfImporter := importer.NewCurseForgeImporter("", apiKey)
|
||||||
|
|
||||||
|
// Извлекаем slug из URL
|
||||||
|
parsedURL, err := parseModpackURL(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid URL: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем проект по slug
|
||||||
|
projectID, err := cfImporter.FindModpackBySlug(parsedURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Modpack not found: %v", err), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем список файлов (версий)
|
||||||
|
files, err := cfImporter.GetModpackFiles(projectID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to get versions: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразуем в ответ
|
||||||
|
var versions []ModpackVersionResponse
|
||||||
|
for _, f := range files {
|
||||||
|
versions = append(versions, ModpackVersionResponse{
|
||||||
|
FileID: f.ID,
|
||||||
|
DisplayName: f.DisplayName,
|
||||||
|
FileName: f.FileName,
|
||||||
|
FileDate: f.FileDate,
|
||||||
|
GameVersions: f.GameVersions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseModpackURL извлекает slug из URL страницы CurseForge
|
||||||
|
func parseModpackURL(rawURL string) (string, error) {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// URL вида https://www.curseforge.com/minecraft/modpacks/all-the-mods-9
|
||||||
|
// path.Base вернет "all-the-mods-9"
|
||||||
|
slug := path.Base(parsed.Path)
|
||||||
|
if slug == "" || slug == "." || slug == "/" {
|
||||||
|
return "", fmt.Errorf("could not extract modpack slug from URL")
|
||||||
|
}
|
||||||
|
return slug, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModpacks возвращает список всех модпаков для админки
|
||||||
|
func (h *ModpackHandler) GetModpacks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
modpacks, err := h.ModpackRepo.GetAllModpacks(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to get modpacks", "error", err)
|
||||||
|
http.Error(w, "Failed to get modpacks", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(modpacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobParams содержит параметры для фоновой задачи обновления
|
||||||
|
type UpdateJobParams struct {
|
||||||
|
ImporterType string
|
||||||
|
ImportMethod string
|
||||||
|
SourceURL string
|
||||||
|
TempZipPath string
|
||||||
|
ModpackID int
|
||||||
|
ModpackName string
|
||||||
|
MCVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModpack обрабатывает запрос на обновление модпака
|
||||||
|
func (h *ModpackHandler) UpdateModpack(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseMultipartForm(512 << 20); err != nil {
|
||||||
|
http.Error(w, "File too large", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modpackName := r.FormValue("modpackName")
|
||||||
|
if modpackName == "" {
|
||||||
|
http.Error(w, "Missing modpack name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем модпак по имени
|
||||||
|
modpack, err := h.ModpackRepo.GetModpackByName(r.Context(), modpackName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Modpack not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params := UpdateJobParams{
|
||||||
|
ImporterType: r.FormValue("importerType"),
|
||||||
|
ImportMethod: r.FormValue("importMethod"),
|
||||||
|
SourceURL: r.FormValue("sourceUrl"),
|
||||||
|
ModpackID: modpack.ID,
|
||||||
|
ModpackName: modpackName,
|
||||||
|
MCVersion: r.FormValue("mcVersion"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.MCVersion == "" {
|
||||||
|
params.MCVersion = modpack.MinecraftVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка загрузки файла
|
||||||
|
if params.ImportMethod == "file" {
|
||||||
|
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-update-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Could not create temp file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(tempFile, file); err != nil {
|
||||||
|
tempFile.Close()
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
http.Error(w, "Could not save temp file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempFile.Close()
|
||||||
|
params.TempZipPath = tempFile.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем задачу в БД
|
||||||
|
jobID, err := h.JobRepo.CreateJob(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
if params.ImportMethod == "file" {
|
||||||
|
os.Remove(params.TempZipPath)
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("Failed to create job: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем worker
|
||||||
|
go h.processUpdateJob(jobID, params)
|
||||||
|
|
||||||
|
// Отправляем ответ
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"job_id": jobID,
|
||||||
|
"message": "Update job started",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// processUpdateJob выполняет обновление модпака в фоне
|
||||||
|
func (h *ModpackHandler) processUpdateJob(jobID int, params UpdateJobParams) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 10, "")
|
||||||
|
|
||||||
|
// Очистка временного файла по завершении
|
||||||
|
if params.TempZipPath != "" {
|
||||||
|
defer os.Remove(params.TempZipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
|
||||||
|
var imp importer.ModpackImporter
|
||||||
|
|
||||||
|
// Настройка импортера
|
||||||
|
switch params.ImporterType {
|
||||||
|
case "simple":
|
||||||
|
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
|
||||||
|
case "curseforge":
|
||||||
|
apiKey := os.Getenv("CURSEFORGE_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "CurseForge API key missing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
|
||||||
|
case "modrinth":
|
||||||
|
imp = importer.NewModrinthImporter(storagePath)
|
||||||
|
default:
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "Invalid importer type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка по URL если нужно
|
||||||
|
if params.ImportMethod == "url" {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusDownloading, 20, "Downloading from URL...")
|
||||||
|
|
||||||
|
if cfImporter, ok := imp.(*importer.CurseForgeImporter); ok {
|
||||||
|
zipPath, err := cfImporter.DownloadModpackFromURL(params.SourceURL)
|
||||||
|
if err != nil {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Download failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.TempZipPath = zipPath
|
||||||
|
defer os.Remove(zipPath)
|
||||||
|
} else {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "URL import not supported for this type")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 40, "Processing modpack files...")
|
||||||
|
|
||||||
|
// Импорт файлов
|
||||||
|
files, err := imp.Import(params.TempZipPath)
|
||||||
|
if err != nil {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Import failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 80, "Updating database...")
|
||||||
|
|
||||||
|
// Обновление в БД
|
||||||
|
err = h.ModpackRepo.UpdateModpackTx(ctx, params.ModpackID, params.MCVersion, files)
|
||||||
|
if err != nil {
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Database update failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.updateJobStatus(ctx, jobID, models.JobStatusCompleted, 100, "Success")
|
||||||
|
|
||||||
|
// Запуск Janitor-а
|
||||||
|
go h.JanitorService.CleanOrphanedFiles(context.Background())
|
||||||
|
}
|
||||||
480
internal/api/openapi.yaml
Normal file
480
internal/api/openapi.yaml
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Minecraft Platform API
|
||||||
|
description: API for Minecraft Server Platform handling auth, skins, modpacks, and servers.
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
description: Local development server
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
# --- Public Auth & User Registration ---
|
||||||
|
/api/register:
|
||||||
|
post:
|
||||||
|
summary: Register a new user
|
||||||
|
tags: [Auth]
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [username, email, password]
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: User registered successfully
|
||||||
|
400:
|
||||||
|
description: Validation error
|
||||||
|
|
||||||
|
/api/login:
|
||||||
|
post:
|
||||||
|
summary: Login user
|
||||||
|
tags: [Auth]
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [email, password]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Login successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
user:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
401:
|
||||||
|
description: Invalid credentials
|
||||||
|
|
||||||
|
# --- Auth Server (Launcher Integration) ---
|
||||||
|
/authserver/authenticate:
|
||||||
|
post:
|
||||||
|
summary: Authenticate (Launcher flow)
|
||||||
|
tags: [Auth]
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
accessToken:
|
||||||
|
type: string
|
||||||
|
clientToken:
|
||||||
|
type: string
|
||||||
|
selectedProfile:
|
||||||
|
$ref: '#/components/schemas/GameProfile'
|
||||||
|
|
||||||
|
# --- Session Server ---
|
||||||
|
/sessionserver/session/minecraft/join:
|
||||||
|
post:
|
||||||
|
summary: Join server (Client-side)
|
||||||
|
tags: [Auth]
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
accessToken:
|
||||||
|
type: string
|
||||||
|
selectedProfile:
|
||||||
|
type: string
|
||||||
|
serverId:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
204:
|
||||||
|
description: Joined successfully
|
||||||
|
|
||||||
|
/sessionserver/session/minecraft/profile/{uuid}:
|
||||||
|
get:
|
||||||
|
summary: Get user profile (Server-side check)
|
||||||
|
tags: [User]
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: uuid
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Profile data
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/GameProfile'
|
||||||
|
|
||||||
|
# --- User Endpoints ---
|
||||||
|
/api/user/me:
|
||||||
|
get:
|
||||||
|
summary: Get current user info
|
||||||
|
tags: [User]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Current user
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
|
||||||
|
/api/user/skin:
|
||||||
|
post:
|
||||||
|
summary: Upload skin
|
||||||
|
tags: [User]
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Skin uploaded
|
||||||
|
|
||||||
|
# --- Servers ---
|
||||||
|
/api/servers:
|
||||||
|
get:
|
||||||
|
summary: Get game servers list
|
||||||
|
tags: [Servers]
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: List of servers
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/GameServer'
|
||||||
|
|
||||||
|
# --- Launcher ---
|
||||||
|
/api/launcher/modpacks/summary:
|
||||||
|
get:
|
||||||
|
summary: Get modpacks summary (for launcher)
|
||||||
|
tags: [Launcher]
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: List of modpacks
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
/api/launcher/modpacks/{name}/manifest:
|
||||||
|
get:
|
||||||
|
summary: Get modpack manifest
|
||||||
|
tags: [Launcher]
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: name
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Modpack manifest
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
hash:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
|
||||||
|
# --- Admin / Modpacks ---
|
||||||
|
/api/admin/modpacks:
|
||||||
|
get:
|
||||||
|
summary: Get all modpacks (Admin)
|
||||||
|
tags: [Admin]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: List of full modpack details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Modpack'
|
||||||
|
|
||||||
|
/api/admin/modpacks/import:
|
||||||
|
post:
|
||||||
|
summary: Import new modpack
|
||||||
|
tags: [Admin]
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
mcVersion:
|
||||||
|
type: string
|
||||||
|
importerType:
|
||||||
|
type: string
|
||||||
|
enum: [simple, curseforge, modrinth]
|
||||||
|
importMethod:
|
||||||
|
type: string
|
||||||
|
enum: [file, url]
|
||||||
|
sourceUrl:
|
||||||
|
type: string
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
202:
|
||||||
|
description: Job started
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
job_id:
|
||||||
|
type: integer
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
/api/admin/modpacks/update:
|
||||||
|
post:
|
||||||
|
summary: Update existing modpack
|
||||||
|
tags: [Admin]
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
modpackName:
|
||||||
|
type: string
|
||||||
|
mcVersion:
|
||||||
|
type: string
|
||||||
|
importerType:
|
||||||
|
type: string
|
||||||
|
importMethod:
|
||||||
|
type: string
|
||||||
|
sourceUrl:
|
||||||
|
type: string
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
responses:
|
||||||
|
202:
|
||||||
|
description: Job started
|
||||||
|
|
||||||
|
/api/admin/modpacks/versions:
|
||||||
|
get:
|
||||||
|
summary: Get modpack versions (CurseForge)
|
||||||
|
tags: [Admin]
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: url
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: List of versions
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
file_id:
|
||||||
|
type: integer
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
game_versions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
# --- Admin / Users ---
|
||||||
|
/api/admin/users:
|
||||||
|
get:
|
||||||
|
summary: Get all users
|
||||||
|
tags: [Admin]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: List of users
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/User'
|
||||||
|
|
||||||
|
/api/admin/users/{id}/role:
|
||||||
|
patch:
|
||||||
|
summary: Update user role
|
||||||
|
tags: [Admin]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum: [user, admin]
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Role updated
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
User:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
GameProfile:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
properties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
signature:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
GameServer:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
address:
|
||||||
|
type: string
|
||||||
|
is_enabled:
|
||||||
|
type: boolean
|
||||||
|
motd:
|
||||||
|
type: string
|
||||||
|
player_count:
|
||||||
|
type: integer
|
||||||
|
max_players:
|
||||||
|
type: integer
|
||||||
|
version_name:
|
||||||
|
type: string
|
||||||
|
ping_proxy_server:
|
||||||
|
type: integer
|
||||||
|
bluemap_url:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
Modpack:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
minecraft_version:
|
||||||
|
type: string
|
||||||
|
is_active:
|
||||||
|
type: boolean
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
73
internal/api/profile_handler.go
Normal file
73
internal/api/profile_handler.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileHandler struct {
|
||||||
|
Service *core.ProfileService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
playerUUIDStr := chi.URLParam(r, "uuid")
|
||||||
|
playerUUID, err := uuid.Parse(playerUUIDStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := h.Service.GetSignedProfile(r.Context(), playerUUID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrUserNotFound) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProfileHandler) UploadSkin(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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
file, header, err := r.FormFile("skin")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid file upload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
err = h.Service.UpdateUserSkin(r.Context(), userID, file, header)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("Skin updated successfully"))
|
||||||
|
}
|
||||||
22
internal/api/server_handler.go
Normal file
22
internal/api/server_handler.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerHandler struct {
|
||||||
|
Repo *database.ServerRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ServerHandler) GetServers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
servers, err := h.Repo.GetAllWithStatus(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to get servers", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(servers)
|
||||||
|
}
|
||||||
84
internal/api/user_handler.go
Normal file
84
internal/api/user_handler.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
"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"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/utils"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if validationErrors := utils.ValidateStruct(req); validationErrors != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(validationErrors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Received registration request", "username", req.Username, "email", req.Email)
|
||||||
|
|
||||||
|
err := h.Service.RegisterNewUser(r.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Service returned error", "error", err)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, database.ErrUserExists):
|
||||||
|
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)
|
||||||
|
default:
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("User registered successfully", "username", req.Username)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMe возвращает информацию о текущем аутентифицированном пользователе
|
||||||
|
func (h *UserHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(ClaimsContextKey).(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Could not get claims from context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// В AuthMiddleware мы не проверяли тип user_id, там json.Number или float64
|
||||||
|
// Обычно jwt-go возвращает float64
|
||||||
|
var userID int
|
||||||
|
if idFloat, ok := claims["user_id"].(float64); ok {
|
||||||
|
userID = int(idFloat)
|
||||||
|
} else {
|
||||||
|
log.Printf("[Handler] Invalid user_id type in claims: %T", claims["user_id"])
|
||||||
|
http.Error(w, "Invalid token claims", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.Service.GetUserByID(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Handler] Failed to get user by ID: %v", err)
|
||||||
|
http.Error(w, "User not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
101
internal/core/auth_service.go
Normal file
101
internal/core/auth_service.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthService struct {
|
||||||
|
UserRepo *database.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate проверяет учетные данные и возвращает данные для ответа Yggdrasil.
|
||||||
|
func (s *AuthService) Authenticate(ctx context.Context, req models.AuthenticateRequest) (*models.AuthenticateResponse, error) {
|
||||||
|
user, err := s.UserRepo.GetUserByUsername(ctx, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrUserNotFound) {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := uuid.New().String()
|
||||||
|
|
||||||
|
err = s.UserRepo.CreateAccessToken(ctx, user.ID, accessToken, req.ClientToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := models.ProfileInfo{
|
||||||
|
ID: strings.ReplaceAll(user.UUID.String(), "-", ""),
|
||||||
|
Name: user.Username,
|
||||||
|
}
|
||||||
|
|
||||||
|
response := &models.AuthenticateResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
ClientToken: req.ClientToken,
|
||||||
|
AvailableProfiles: []models.ProfileInfo{profile},
|
||||||
|
SelectedProfile: profile,
|
||||||
|
User: &models.UserProperty{
|
||||||
|
ID: user.UUID.String(),
|
||||||
|
Properties: []any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginUser проверяет учетные данные и генерирует JWT для веб-сессии.
|
||||||
|
func (s *AuthService) LoginUser(ctx context.Context, req models.LoginRequest) (string, *models.User, error) {
|
||||||
|
user, err := s.UserRepo.GetUserByLogin(ctx, req.Login)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, database.ErrUserNotFound) {
|
||||||
|
return "", nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
expirationTime := time.Now().Add(72 * time.Hour)
|
||||||
|
|
||||||
|
claims := &jwt.MapClaims{
|
||||||
|
"exp": expirationTime.Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"user_id": user.ID,
|
||||||
|
"role": user.Role,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
289
internal/core/importer/curseforge.go
Normal file
289
internal/core/importer/curseforge.go
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CurseForgeImporter реализует импорт для сборок CurseForge.
|
||||||
|
type CurseForgeImporter struct {
|
||||||
|
StoragePath string
|
||||||
|
APIKey string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
HTTPClientLong *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCurseForgeImporter создает новый экземпляр импортера.
|
||||||
|
func NewCurseForgeImporter(storagePath, apiKey string) *CurseForgeImporter {
|
||||||
|
return &CurseForgeImporter{
|
||||||
|
StoragePath: storagePath,
|
||||||
|
APIKey: apiKey,
|
||||||
|
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
HTTPClientLong: &http.Client{Timeout: 10 * time.Minute},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadAndProcessFile скачивает файл по URL и передает его в общий обработчик.
|
||||||
|
func (i *CurseForgeImporter) downloadAndProcessFile(url string) (hash string, size int64, err error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := i.HTTPClientLong.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", 0, fmt.Errorf("bad status downloading file: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
|
||||||
|
return baseImporter.processFile(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFileInfo получает полную информацию о файле, включая URL для скачивания.
|
||||||
|
func (i *CurseForgeImporter) getFileInfo(projectID, fileID int) (*CurseForgeFile, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.curseforge.com/v1/mods/%d/files/%d", projectID, fileID)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("x-api-key", i.APIKey)
|
||||||
|
|
||||||
|
resp, err := i.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("bad status getting file info for fileID %d: %s", fileID, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileInfo CurseForgeFile
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&fileInfo); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode file info response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &fileInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindModpackBySlug ищет ID проекта по его "слагу" (части URL).
|
||||||
|
func (i *CurseForgeImporter) FindModpackBySlug(slug string) (int, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.curseforge.com/v1/mods/search?gameId=432&slug=%s", url.QueryEscape(slug))
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("x-api-key", i.APIKey)
|
||||||
|
|
||||||
|
resp, err := i.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var searchResp CurseForgeSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data) == 0 {
|
||||||
|
return 0, fmt.Errorf("modpack with slug '%s' not found", slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchResp.Data[0].ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLatestModpackFileURL находит URL для скачивания последнего файла проекта.
|
||||||
|
func (i *CurseForgeImporter) getLatestModpackFileURL(projectID int) (string, error) {
|
||||||
|
files, err := i.GetModpackFiles(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return "", fmt.Errorf("no files found for projectID %d", projectID)
|
||||||
|
}
|
||||||
|
|
||||||
|
latestFile := files[0]
|
||||||
|
return latestFile.DownloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModpackFiles возвращает список файлов (версий) для проекта.
|
||||||
|
func (i *CurseForgeImporter) GetModpackFiles(projectID int) ([]CurseForgeFileData, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.curseforge.com/v1/mods/%d/files", projectID)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("x-api-key", i.APIKey)
|
||||||
|
|
||||||
|
resp, err := i.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var filesResp CurseForgeFilesResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&filesResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesResp.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadModpackFromURL скачивает zip-архив модпака по URL страницы CurseForge.
|
||||||
|
func (i *CurseForgeImporter) DownloadModpackFromURL(pageURL string) (string, error) {
|
||||||
|
parsedURL, err := url.Parse(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid url: %w", err)
|
||||||
|
}
|
||||||
|
slug := path.Base(parsedURL.Path)
|
||||||
|
log.Printf("Importer: Extracted slug '%s' from URL", slug)
|
||||||
|
|
||||||
|
projectID, err := i.FindModpackBySlug(slug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Printf("Importer: Found projectID %d for slug '%s'", projectID, slug)
|
||||||
|
|
||||||
|
downloadURL, err := i.getLatestModpackFileURL(projectID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Printf("Importer: Found download URL: %s", downloadURL)
|
||||||
|
|
||||||
|
tempFile, err := os.CreateTemp("", "modpack-*.zip")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := i.HTTPClientLong.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(tempFile, resp.Body); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Importer: Successfully downloaded modpack to %s", tempFile.Name())
|
||||||
|
return tempFile.Name(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import реализует основной метод для CurseForge.
|
||||||
|
func (i *CurseForgeImporter) Import(zipPath string) ([]models.ModpackFile, error) {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
var manifestFile *zip.File
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name == "manifest.json" {
|
||||||
|
manifestFile = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manifestFile == nil {
|
||||||
|
return nil, fmt.Errorf("manifest.json not found in archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
mfReader, err := manifestFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer mfReader.Close()
|
||||||
|
|
||||||
|
var manifest CurseForgeManifest
|
||||||
|
if err := json.NewDecoder(mfReader).Decode(&manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse manifest.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []models.ModpackFile
|
||||||
|
|
||||||
|
for _, modFile := range manifest.Files {
|
||||||
|
fileInfo, err := i.getFileInfo(modFile.ProjectID, modFile.FileID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Importer: WARN - Could not get info for fileID %d, skipping. Error: %v", modFile.FileID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadURL := fileInfo.Data.DownloadURL
|
||||||
|
fileName := fileInfo.Data.FileName
|
||||||
|
|
||||||
|
// ИСПРАВЛЕНИЕ: Проверяем, что URL не пустой
|
||||||
|
if downloadURL == "" {
|
||||||
|
log.Printf("Importer: WARN - Empty download URL for file '%s' (fileID %d), skipping.", fileName, modFile.FileID)
|
||||||
|
continue // Пропускаем этот файл
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, size, err := i.downloadAndProcessFile(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
// Вместо того чтобы падать, просто логируем ошибку и пропускаем файл
|
||||||
|
log.Printf("Importer: WARN - Failed to process downloaded file '%s' (fileID %d), skipping. Error: %v", fileName, modFile.FileID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
relativePath := filepath.Join("mods", fileName)
|
||||||
|
|
||||||
|
files = append(files, models.ModpackFile{
|
||||||
|
RelativePath: relativePath,
|
||||||
|
FileHash: hash,
|
||||||
|
FileSize: size,
|
||||||
|
DownloadURL: downloadURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
|
||||||
|
for _, f := range r.File {
|
||||||
|
if strings.HasPrefix(f.Name, manifest.Overrides+"/") && !f.FileInfo().IsDir() {
|
||||||
|
fileReader, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, size, err := baseImporter.processFile(fileReader)
|
||||||
|
if err != nil {
|
||||||
|
fileReader.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fileReader.Close()
|
||||||
|
|
||||||
|
relativePath := strings.TrimPrefix(f.Name, manifest.Overrides+"/")
|
||||||
|
files = append(files, models.ModpackFile{
|
||||||
|
RelativePath: relativePath,
|
||||||
|
FileHash: hash,
|
||||||
|
FileSize: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
58
internal/core/importer/curseforge_types.go
Normal file
58
internal/core/importer/curseforge_types.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
type CurseForgeManifest struct {
|
||||||
|
Minecraft struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
ModLoaders []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
} `json:"modLoaders"`
|
||||||
|
} `json:"minecraft"`
|
||||||
|
ManifestType string `json:"manifestType"`
|
||||||
|
ManifestVersion int `json:"manifestVersion"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Files []struct {
|
||||||
|
ProjectID int `json:"projectID"`
|
||||||
|
FileID int `json:"fileID"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
} `json:"files"`
|
||||||
|
Overrides string `json:"overrides"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurseForgeSearchResponse - ответ от эндпоинта поиска
|
||||||
|
type CurseForgeSearchResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurseForgeFilesResponse - ответ от эндпоинта получения файлов проекта
|
||||||
|
type CurseForgeFilesResponse struct {
|
||||||
|
Data []CurseForgeFileData `json:"data"`
|
||||||
|
Pagination struct {
|
||||||
|
TotalCount int `json:"totalCount"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurseForgeFileData - данные о файле
|
||||||
|
type CurseForgeFileData struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
FileDate string `json:"fileDate"`
|
||||||
|
GameVersions []string `json:"gameVersions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurseForgeFile представляет полную информацию о файле с API.
|
||||||
|
type CurseForgeFile struct {
|
||||||
|
Data struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
DownloadURL string `json:"downloadUrl"`
|
||||||
|
// ... можно добавить другие поля, если понадобятся
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
161
internal/core/importer/modrinth.go
Normal file
161
internal/core/importer/modrinth.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModrinthImporter реализует импорт для сборок Modrinth (.mrpack).
|
||||||
|
type ModrinthImporter struct {
|
||||||
|
StoragePath string
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewModrinthImporter создает новый экземпляр импортера.
|
||||||
|
func NewModrinthImporter(storagePath string) *ModrinthImporter {
|
||||||
|
return &ModrinthImporter{
|
||||||
|
StoragePath: storagePath,
|
||||||
|
HTTPClient: &http.Client{Timeout: 10 * time.Minute},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadAndProcessFile скачивает файл и обрабатывает его.
|
||||||
|
func (i *ModrinthImporter) downloadAndProcessFile(url string) (hash string, size int64, err error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := i.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", 0, fmt.Errorf("bad status downloading file: %s", resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
|
||||||
|
return baseImporter.processFile(resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import реализует основной метод интерфейса ModpackImporter.
|
||||||
|
func (i *ModrinthImporter) Import(zipPath string) ([]models.ModpackFile, error) {
|
||||||
|
r, err := zip.OpenReader(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
|
||||||
|
// 1. Ищем и парсим modrinth.index.json
|
||||||
|
var indexFile *zip.File
|
||||||
|
for _, f := range r.File {
|
||||||
|
if f.Name == "modrinth.index.json" {
|
||||||
|
indexFile = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if indexFile == nil {
|
||||||
|
return nil, fmt.Errorf("modrinth.index.json not found in archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
idxReader, err := indexFile.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer idxReader.Close()
|
||||||
|
|
||||||
|
var index ModrinthIndex
|
||||||
|
if err := json.NewDecoder(idxReader).Decode(&index); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse modrinth.index.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []models.ModpackFile
|
||||||
|
|
||||||
|
// 2. Обрабатываем файлы из индекса (скачиваем их)
|
||||||
|
for _, modFile := range index.Files {
|
||||||
|
// Пропускаем серверные файлы, которые не нужны клиенту?
|
||||||
|
// В ТЗ сказано про "клиент", но обычно лаунчер качает всё, что нужно клиенту.
|
||||||
|
// env.client != "unsupported" (обычно "required" или "optional")
|
||||||
|
if modFile.Env.Client == "unsupported" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(modFile.Downloads) == 0 {
|
||||||
|
log.Printf("Modrinth Importer: WARN - No download URLs for file '%s', skipping.", modFile.Path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем первый доступный URL
|
||||||
|
downloadURL := modFile.Downloads[0]
|
||||||
|
|
||||||
|
log.Printf("Modrinth Importer: Downloading '%s' from %s", modFile.Path, downloadURL)
|
||||||
|
|
||||||
|
// Скачиваем файл и пересчитываем его хеш/размер (и сохраняем локально, если нужно)
|
||||||
|
// Важно: Modrinth дает хеши в индексе. Мы могли бы использовать их, но наша система
|
||||||
|
// построена на том, что мы храним файлы у себя. Поэтому нам всё равно нужно скачать их
|
||||||
|
// и сохранить в наше хранилище. Метод downloadAndProcessFile делает именно это.
|
||||||
|
hash, size, err := i.downloadAndProcessFile(downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Modrinth Importer: WARN - Failed to download/process '%s': %v", modFile.Path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сверка хеша для надежности (опционально, но полезно)
|
||||||
|
if modFile.Hashes.SHA1 != "" && modFile.Hashes.SHA1 != hash {
|
||||||
|
log.Printf("Modrinth Importer: WARN - Hash mismatch for '%s'. Index SHA1: %s, Calculated: %s", modFile.Path, modFile.Hashes.SHA1, hash)
|
||||||
|
// Можно решить: падать с ошибкой или доверять тому, что скачали.
|
||||||
|
// Пока просто предупреждаем.
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, models.ModpackFile{
|
||||||
|
RelativePath: modFile.Path,
|
||||||
|
FileHash: hash,
|
||||||
|
FileSize: size,
|
||||||
|
DownloadURL: downloadURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Обрабатываем overrides
|
||||||
|
baseImporter := SimpleZipImporter{StoragePath: i.StoragePath}
|
||||||
|
for _, f := range r.File {
|
||||||
|
// В .mrpack файлы для копирования лежат в папке "overrides/" (как правило)
|
||||||
|
// Спецификация говорит, что папка может называться иначе? Нет, обычно overrides.
|
||||||
|
// Но лучше проверить все папки, кроме системных.
|
||||||
|
// Спецификация Modrinth: "Files that are included in the modpack archive are located in the overrides directory."
|
||||||
|
|
||||||
|
prefix := "overrides/"
|
||||||
|
if strings.HasPrefix(f.Name, prefix) && !f.FileInfo().IsDir() {
|
||||||
|
fileReader, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, size, err := baseImporter.processFile(fileReader)
|
||||||
|
if err != nil {
|
||||||
|
fileReader.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fileReader.Close()
|
||||||
|
|
||||||
|
relativePath := strings.TrimPrefix(f.Name, prefix)
|
||||||
|
files = append(files, models.ModpackFile{
|
||||||
|
RelativePath: relativePath,
|
||||||
|
FileHash: hash,
|
||||||
|
FileSize: size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
24
internal/core/importer/modrinth_types.go
Normal file
24
internal/core/importer/modrinth_types.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
// ModrinthIndex - структура modrinth.index.json
|
||||||
|
type ModrinthIndex struct {
|
||||||
|
FormatVersion int `json:"formatVersion"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
VersionID string `json:"versionId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Files []struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Hashes struct {
|
||||||
|
SHA1 string `json:"sha1"`
|
||||||
|
SHA512 string `json:"sha512"`
|
||||||
|
} `json:"hashes"`
|
||||||
|
Env struct {
|
||||||
|
Client string `json:"client"`
|
||||||
|
Server string `json:"server"`
|
||||||
|
} `json:"env"`
|
||||||
|
Downloads []string `json:"downloads"`
|
||||||
|
FileSize int64 `json:"fileSize"` // В спецификации это поле может быть uint64, но int64 удобнее
|
||||||
|
} `json:"files"`
|
||||||
|
Dependencies map[string]string `json:"dependencies"`
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
160
internal/core/profile_service.go
Normal file
160
internal/core/profile_service.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProfileService struct {
|
||||||
|
UserRepo *database.UserRepository
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileService создает новый сервис и загружает приватный ключ.
|
||||||
|
func NewProfileService(repo *database.UserRepository, keyPath, domain string) (*ProfileService, error) {
|
||||||
|
keyData, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read private key file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(keyData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("failed to parse PEM block containing the key")
|
||||||
|
}
|
||||||
|
|
||||||
|
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if errPkcs8 != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %v / %v", err, errPkcs8)
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
privateKey, ok = key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("key is not an RSA private key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProfileService{
|
||||||
|
UserRepo: repo,
|
||||||
|
privateKey: privateKey,
|
||||||
|
domain: domain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSignedProfile формирует и подписывает профиль игрока.
|
||||||
|
func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.UUID) (*models.SessionProfileResponse, error) {
|
||||||
|
user, profile, err := s.UserRepo.GetProfileByUUID(ctx, playerUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
textures := models.Textures{}
|
||||||
|
if profile.SkinHash != "" {
|
||||||
|
textures.SKIN = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.SkinHash)}
|
||||||
|
}
|
||||||
|
if profile.CapeHash != "" {
|
||||||
|
textures.CAPE = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.CapeHash)}
|
||||||
|
}
|
||||||
|
|
||||||
|
profileID := strings.ReplaceAll(user.UUID.String(), "-", "")
|
||||||
|
propValue := models.ProfilePropertyValue{
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
ProfileID: profileID,
|
||||||
|
ProfileName: user.Username,
|
||||||
|
Textures: textures,
|
||||||
|
}
|
||||||
|
|
||||||
|
valueJSON, err := json.Marshal(propValue)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
valueBase64 := base64.StdEncoding.EncodeToString(valueJSON)
|
||||||
|
|
||||||
|
hasher := sha1.New()
|
||||||
|
hasher.Write([]byte(valueBase64))
|
||||||
|
hashed := hasher.Sum(nil)
|
||||||
|
|
||||||
|
signature, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, hashed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signatureBase64 := base64.StdEncoding.EncodeToString(signature)
|
||||||
|
|
||||||
|
response := &models.SessionProfileResponse{
|
||||||
|
ID: profileID,
|
||||||
|
Name: user.Username,
|
||||||
|
Properties: []models.ProfileProperty{
|
||||||
|
{
|
||||||
|
Name: "textures",
|
||||||
|
Value: valueBase64,
|
||||||
|
Signature: signatureBase64,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserSkin обрабатывает загрузку, валидацию и сохранение файла скина.
|
||||||
|
func (s *ProfileService) UpdateUserSkin(ctx context.Context, userID int, file multipart.File, header *multipart.FileHeader) error {
|
||||||
|
fileBytes, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file: %w", err)
|
||||||
|
}
|
||||||
|
file.Seek(0, 0)
|
||||||
|
|
||||||
|
config, err := png.DecodeConfig(file)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid PNG file")
|
||||||
|
}
|
||||||
|
if config.Width != 64 || config.Height != 64 {
|
||||||
|
return errors.New("skin must be 64x64 pixels")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher := sha1.New()
|
||||||
|
hasher.Write(fileBytes)
|
||||||
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("failed to create skin file: %w", err)
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
_, err = outFile.Write(fileBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write skin file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.UserRepo.UpdateSkinHash(ctx, userID, hash)
|
||||||
|
}
|
||||||
90
internal/core/server_poller.go
Normal file
90
internal/core/server_poller.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ServerPoller) Start(ctx context.Context) {
|
||||||
|
slog.Info("Starting server poller...")
|
||||||
|
ticker := time.NewTicker(60 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
p.pollAllServers(ctx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
p.pollAllServers(ctx)
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("Stopping server poller...")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ServerPoller) pollAllServers(ctx context.Context) {
|
||||||
|
servers, err := p.Repo.GetAllEnabledServers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Poller: failed to get servers", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range servers {
|
||||||
|
go p.pollServer(ctx, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ServerPoller) pollServer(ctx context.Context, server *models.GameServer) {
|
||||||
|
resp, delay, err := bot.PingAndList(server.Address)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Poller: failed to ping server", "server", server.Name, "address", server.Address, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var status pingResponse
|
||||||
|
if err := json.Unmarshal(resp, &status); err != nil {
|
||||||
|
slog.Error("Poller: failed to unmarshal status", "server", server.Name, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
motdText := status.Description.String()
|
||||||
|
|
||||||
|
updateData := &models.ServerStatus{
|
||||||
|
StatusJSON: string(resp),
|
||||||
|
Motd: motdText,
|
||||||
|
PlayerCount: status.Players.Online,
|
||||||
|
MaxPlayers: status.Players.Max,
|
||||||
|
VersionName: status.Version.Name,
|
||||||
|
Ping: delay.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Repo.UpdateServerStatus(ctx, server.ID, updateData); err != nil {
|
||||||
|
slog.Error("Poller: failed to update status", "server", server.Name, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Poller: successfully polled server", "server", server.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
99
internal/core/user_service.go
Normal file
99
internal/core/user_service.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"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)")
|
||||||
|
)
|
||||||
|
|
||||||
|
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,16}$`)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
if !usernameRegex.MatchString(req.Username) {
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
if !emailRegex.MatchString(req.Email) {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
|
if len(req.Password) < 8 {
|
||||||
|
return ErrPasswordTooShort
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID возвращает пользователя по его ID
|
||||||
|
func (s *UserService) GetUserByID(ctx context.Context, userID int) (*models.User, error) {
|
||||||
|
return s.Repo.GetUserByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateJoinRequest проверяет запрос на присоединение к серверу.
|
||||||
|
func (s *AuthService) ValidateJoinRequest(ctx context.Context, req models.JoinRequest) error {
|
||||||
|
var uuidStr string
|
||||||
|
if len(req.SelectedProfile) == 32 {
|
||||||
|
uuidStr = fmt.Sprintf("%s-%s-%s-%s-%s",
|
||||||
|
req.SelectedProfile[0:8],
|
||||||
|
req.SelectedProfile[8:12],
|
||||||
|
req.SelectedProfile[12:16],
|
||||||
|
req.SelectedProfile[16:20],
|
||||||
|
req.SelectedProfile[20:32],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return errors.New("invalid profile UUID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
userUUID, err := uuid.Parse(uuidStr)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("User %s successfully joined server with serverId %s", userUUID, req.ServerID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
internal/database/job_repository.go
Normal file
58
internal/database/job_repository.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobRepository struct {
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJob создает новую задачу со статусом pending
|
||||||
|
func (r *JobRepository) CreateJob(ctx context.Context) (int, error) {
|
||||||
|
var jobID int
|
||||||
|
query := `INSERT INTO modpack_import_jobs (status, progress, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, NOW(), NOW()) RETURNING id`
|
||||||
|
|
||||||
|
err := r.DB.QueryRow(ctx, query, models.JobStatusPending, 0).Scan(&jobID)
|
||||||
|
return jobID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobStatus обновляет статус и прогресс задачи
|
||||||
|
func (r *JobRepository) UpdateJobStatus(ctx context.Context, jobID int, status models.ImportJobStatus, progress int, errorMessage string) error {
|
||||||
|
query := `UPDATE modpack_import_jobs
|
||||||
|
SET status = $1, progress = $2, error_message = $3, updated_at = NOW()
|
||||||
|
WHERE id = $4`
|
||||||
|
|
||||||
|
_, err := r.DB.Exec(ctx, query, status, progress, errorMessage, jobID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJob получает задачу по ID
|
||||||
|
func (r *JobRepository) GetJob(ctx context.Context, jobID int) (*models.ImportJob, error) {
|
||||||
|
job := &models.ImportJob{}
|
||||||
|
query := `SELECT id, status, progress, error_message, created_at, updated_at
|
||||||
|
FROM modpack_import_jobs WHERE id = $1`
|
||||||
|
|
||||||
|
var errMsg *string // Для обработки NULL
|
||||||
|
err := r.DB.QueryRow(ctx, query, jobID).Scan(
|
||||||
|
&job.ID, &job.Status, &job.Progress, &errMsg, &job.CreatedAt, &job.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return nil, nil // Или спец ошибка
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg != nil {
|
||||||
|
job.ErrorMessage = *errMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
210
internal/database/modpack_repository.go
Normal file
210
internal/database/modpack_repository.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModpacksSummary возвращает список всех активных модпаков с датой последнего обновления.
|
||||||
|
func (r *ModpackRepository) GetModpacksSummary(ctx context.Context) ([]models.ModpackSummary, error) {
|
||||||
|
query := `
|
||||||
|
SELECT name, updated_at
|
||||||
|
FROM modpacks
|
||||||
|
WHERE is_active = TRUE`
|
||||||
|
|
||||||
|
rows, err := r.DB.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var summaries []models.ModpackSummary
|
||||||
|
for rows.Next() {
|
||||||
|
var s models.ModpackSummary
|
||||||
|
if err := rows.Scan(&s.Name, &s.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
summaries = append(summaries, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllModpacks возвращает список всех модпаков для админки.
|
||||||
|
func (r *ModpackRepository) GetAllModpacks(ctx context.Context) ([]models.Modpack, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, display_name, minecraft_version, is_active, created_at, updated_at
|
||||||
|
FROM modpacks
|
||||||
|
ORDER BY name`
|
||||||
|
|
||||||
|
rows, err := r.DB.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var modpacks []models.Modpack
|
||||||
|
for rows.Next() {
|
||||||
|
var m models.Modpack
|
||||||
|
if err := rows.Scan(&m.ID, &m.Name, &m.DisplayName, &m.MinecraftVersion, &m.IsActive, &m.CreatedAt, &m.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
modpacks = append(modpacks, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return modpacks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModpackTx обновляет файлы модпака в транзакции: удаляет старые, добавляет новые.
|
||||||
|
func (r *ModpackRepository) UpdateModpackTx(ctx context.Context, modpackID int, mcVersion string, files []models.ModpackFile) error {
|
||||||
|
tx, err := r.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Обновляем версию Minecraft и updated_at
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
"UPDATE modpacks SET minecraft_version = $1, updated_at = NOW() WHERE id = $2",
|
||||||
|
mcVersion, modpackID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем старые файлы
|
||||||
|
_, err = tx.Exec(ctx, "DELETE FROM modpack_files WHERE modpack_id = $1", 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModpackByName возвращает модпак по имени.
|
||||||
|
func (r *ModpackRepository) GetModpackByName(ctx context.Context, name string) (*models.Modpack, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, display_name, minecraft_version, is_active, created_at, updated_at
|
||||||
|
FROM modpacks
|
||||||
|
WHERE name = $1`
|
||||||
|
|
||||||
|
var m models.Modpack
|
||||||
|
err := r.DB.QueryRow(ctx, query, name).Scan(&m.ID, &m.Name, &m.DisplayName, &m.MinecraftVersion, &m.IsActive, &m.CreatedAt, &m.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
31
internal/database/postgres.go
Normal file
31
internal/database/postgres.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connect устанавливает соединение с базой данных PostgreSQL, используя пул соединений pgx.
|
||||||
|
func Connect() *pgxpool.Pool {
|
||||||
|
connStr := os.Getenv("DATABASE_URL")
|
||||||
|
if connStr == "" {
|
||||||
|
log.Fatal("DATABASE_URL environment variable is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем пул соединений
|
||||||
|
pool, err := pgxpool.New(context.Background(), connStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to create connection pool: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что соединение действительно установлено
|
||||||
|
if err = pool.Ping(context.Background()); err != nil {
|
||||||
|
log.Fatalf("Unable to ping database: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Successfully connected to PostgreSQL using pgxpool!")
|
||||||
|
return pool
|
||||||
|
}
|
||||||
68
internal/database/server_repository.go
Normal file
68
internal/database/server_repository.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerRepository struct {
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllEnabledServers возвращает все активные серверы для опроса.
|
||||||
|
func (r *ServerRepository) GetAllEnabledServers(ctx context.Context) ([]*models.GameServer, error) {
|
||||||
|
rows, err := r.DB.Query(ctx, "SELECT id, name, address FROM game_servers WHERE is_enabled = TRUE")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []*models.GameServer
|
||||||
|
for rows.Next() {
|
||||||
|
s := &models.GameServer{}
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name, &s.Address); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateServerStatus обновляет данные о статусе сервера.
|
||||||
|
func (r *ServerRepository) UpdateServerStatus(ctx context.Context, id int, status *models.ServerStatus) error {
|
||||||
|
query := `
|
||||||
|
UPDATE game_servers SET
|
||||||
|
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.Exec(ctx, query,
|
||||||
|
status.StatusJSON, status.Motd, status.PlayerCount, status.MaxPlayers,
|
||||||
|
status.VersionName, status.Ping, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllWithStatus возвращает все активные серверы с их текущим статусом.
|
||||||
|
func (r *ServerRepository) GetAllWithStatus(ctx context.Context) ([]*models.GameServer, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, address, is_enabled, last_polled_at, motd,
|
||||||
|
player_count, max_players, version_name, ping_backend_server, bluemap_url
|
||||||
|
FROM game_servers WHERE is_enabled = TRUE ORDER BY name`
|
||||||
|
rows, err := r.DB.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []*models.GameServer
|
||||||
|
for rows.Next() {
|
||||||
|
s := &models.GameServer{}
|
||||||
|
if err := rows.Scan(&s.ID, &s.Name, &s.Address, &s.IsEnabled, &s.LastPolledAt,
|
||||||
|
&s.Motd, &s.PlayerCount, &s.MaxPlayers, &s.VersionName, &s.PingBackendServer, &s.BlueMapURL); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
servers = append(servers, s)
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
250
internal/database/user_repository.go
Normal file
250
internal/database/user_repository.go
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUserExists = errors.New("user with this username or email already exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRepository struct {
|
||||||
|
DB *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUserTx создает нового пользователя и его профиль в рамках одной транзакции
|
||||||
|
func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) error {
|
||||||
|
log.Println("[DB] Beginning CreateUserTx transaction")
|
||||||
|
tx, err := r.DB.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DB] ERROR: Failed to begin transaction: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
log.Println("[DB] Checking if user exists...")
|
||||||
|
var exists bool
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)", user.Username, user.Email).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DB] ERROR: User existence check failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
log.Printf("[DB] User with username '%s' or email '%s' already exists.", user.Username, user.Email)
|
||||||
|
return ErrUserExists
|
||||||
|
}
|
||||||
|
log.Println("[DB] User does not exist, proceeding.")
|
||||||
|
|
||||||
|
log.Println("[DB] Inserting into 'users' table...")
|
||||||
|
var newUserID int
|
||||||
|
err = tx.QueryRow(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 {
|
||||||
|
log.Printf("[DB] ERROR: Insert into 'users' failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[DB] Inserted into 'users' table successfully. New user ID: %d", newUserID)
|
||||||
|
|
||||||
|
log.Println("[DB] Inserting into 'profiles' table...")
|
||||||
|
_, err = tx.Exec(ctx, "INSERT INTO profiles (user_id) VALUES ($1)", newUserID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DB] ERROR: Insert into 'profiles' failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Println("[DB] Inserted into 'profiles' table successfully.")
|
||||||
|
|
||||||
|
log.Println("[DB] Committing transaction...")
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetUserByUsername находит пользователя по его имени.
|
||||||
|
// Возвращает полную структуру User, включая хеш пароля для проверки.
|
||||||
|
func (r *UserRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||||
|
user := &models.User{}
|
||||||
|
var userUUID string
|
||||||
|
|
||||||
|
query := "SELECT id, uuid, username, email, password_hash, role FROM users WHERE username = $1"
|
||||||
|
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, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.UUID, _ = uuid.Parse(userUUID)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Exec(ctx, query, userID, accessToken, clientToken)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfileByUUID находит пользователя и его профиль по UUID.
|
||||||
|
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
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT u.id, u.username, p.skin_hash, p.cape_hash
|
||||||
|
FROM users u
|
||||||
|
JOIN profiles p ON u.id = p.user_id
|
||||||
|
WHERE u.uuid = $1`
|
||||||
|
|
||||||
|
err := r.DB.QueryRow(ctx, query, userUUID).Scan(
|
||||||
|
&user.ID, &user.Username, &skinHash, &capeHash,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if skinHash.Valid {
|
||||||
|
profile.SkinHash = skinHash.String
|
||||||
|
}
|
||||||
|
if capeHash.Valid {
|
||||||
|
profile.CapeHash = capeHash.String
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTokenNotFound = errors.New("access token not found or invalid")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidateAccessToken проверяет, действителен ли токен для данного пользователя.
|
||||||
|
func (r *UserRepository) ValidateAccessToken(ctx context.Context, token string, userUUID uuid.UUID) error {
|
||||||
|
var exists bool
|
||||||
|
query := `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM access_tokens at
|
||||||
|
JOIN users u ON at.user_id = u.id
|
||||||
|
WHERE at.access_token = $1 AND u.uuid = $2
|
||||||
|
)`
|
||||||
|
|
||||||
|
err := r.DB.QueryRow(ctx, query, token, userUUID).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return ErrTokenNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Exec(ctx, query, skinHash, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByLogin находит пользователя по его имени или email.
|
||||||
|
func (r *UserRepository) GetUserByLogin(ctx context.Context, login string) (*models.User, error) {
|
||||||
|
user := &models.User{}
|
||||||
|
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.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, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.UUID, _ = uuid.Parse(userUUID)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID находит пользователя по его ID.
|
||||||
|
func (r *UserRepository) GetUserByID(ctx context.Context, userID int) (*models.User, error) {
|
||||||
|
user := &models.User{}
|
||||||
|
var userUUID string
|
||||||
|
|
||||||
|
query := "SELECT id, uuid, username, email, password_hash, role, created_at, updated_at FROM users WHERE id = $1"
|
||||||
|
err := r.DB.QueryRow(ctx, query, userID).Scan(
|
||||||
|
&user.ID, &userUUID, &user.Username, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt, &user.UpdatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.UUID, _ = uuid.Parse(userUUID)
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
88
internal/models/auth.go
Normal file
88
internal/models/auth.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// Agent представляет информацию о лаунчере, который делает запрос
|
||||||
|
type Agent struct {
|
||||||
|
Name string `json:"name"` // "Minecraft"
|
||||||
|
Version int `json:"version"` // 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateRequest - это тело запроса на /authserver/authenticate
|
||||||
|
type AuthenticateRequest struct {
|
||||||
|
Agent Agent `json:"agent"`
|
||||||
|
Username string `json:"username" validate:"required"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
ClientToken string `json:"clientToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileInfo содержит краткую информацию о профиле игрока
|
||||||
|
type ProfileInfo struct {
|
||||||
|
ID string `json:"id"` // UUID пользователя без дефисов
|
||||||
|
Name string `json:"name"` // Никнейм пользователя
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticateResponse - это тело успешного ответа
|
||||||
|
type AuthenticateResponse struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
ClientToken string `json:"clientToken"`
|
||||||
|
AvailableProfiles []ProfileInfo `json:"availableProfiles"`
|
||||||
|
SelectedProfile ProfileInfo `json:"selectedProfile"`
|
||||||
|
User *UserProperty `json:"user,omitempty"` // Необязательное поле с доп. свойствами
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserProperty - часть ответа, может содержать доп. свойства пользователя
|
||||||
|
type UserProperty struct {
|
||||||
|
ID string `json:"id"` // UUID пользователя
|
||||||
|
Properties []any `json:"properties"` // Обычно пустой массив
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextureInfo содержит URL для конкретной текстуры
|
||||||
|
type TextureInfo struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textures содержит ссылки на скин и плащ
|
||||||
|
type Textures struct {
|
||||||
|
SKIN *TextureInfo `json:"SKIN,omitempty"`
|
||||||
|
CAPE *TextureInfo `json:"CAPE,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfilePropertyValue - это закодированное в Base64 значение свойства textures
|
||||||
|
type ProfilePropertyValue struct {
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
ProfileID string `json:"profileId"`
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
Textures Textures `json:"textures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileProperty - это свойство 'textures' в ответе
|
||||||
|
type ProfileProperty struct {
|
||||||
|
Name string `json:"name"` // Всегда "textures"
|
||||||
|
Value string `json:"value"` // Base64(ProfilePropertyValue)
|
||||||
|
Signature string `json:"signature"` // Base64(RSA-SHA1(Value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionProfileResponse - это ответ от /sessionserver/session/minecraft/profile/{uuid}
|
||||||
|
type SessionProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Properties []ProfileProperty `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinRequest - это тело запроса на /sessionserver/session/minecraft/join
|
||||||
|
type JoinRequest struct {
|
||||||
|
AccessToken string `json:"accessToken" validate:"required"`
|
||||||
|
SelectedProfile string `json:"selectedProfile" validate:"required"` // UUID пользователя без дефисов
|
||||||
|
ServerID string `json:"serverId" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest - это тело запроса на /api/login
|
||||||
|
type LoginRequest struct {
|
||||||
|
Login string `json:"login" validate:"required"`
|
||||||
|
Password string `json:"password" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse - это тело успешного ответа с JWT
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User *User `json:"user"`
|
||||||
|
}
|
||||||
24
internal/models/job.go
Normal file
24
internal/models/job.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ImportJobStatus определяет возможные статусы задачи импорта
|
||||||
|
type ImportJobStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
JobStatusPending ImportJobStatus = "pending"
|
||||||
|
JobStatusDownloading ImportJobStatus = "downloading"
|
||||||
|
JobStatusProcessing ImportJobStatus = "processing"
|
||||||
|
JobStatusCompleted ImportJobStatus = "completed"
|
||||||
|
JobStatusFailed ImportJobStatus = "failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImportJob представляет задачу на импорт модпака
|
||||||
|
type ImportJob struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Status ImportJobStatus `json:"status"`
|
||||||
|
Progress int `json:"progress"` // 0-100
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
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"`
|
||||||
|
}
|
||||||
29
internal/models/modpack.go
Normal file
29
internal/models/modpack.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModpackSummary используется лаунчером для проверки наличия обновлений
|
||||||
|
type ModpackSummary struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModpackFile представляет метаданные одного файла в модпаке
|
||||||
|
type ModpackFile struct {
|
||||||
|
ModpackID int
|
||||||
|
RelativePath string
|
||||||
|
FileHash string
|
||||||
|
FileSize int64
|
||||||
|
DownloadURL string
|
||||||
|
}
|
||||||
27
internal/models/server.go
Normal file
27
internal/models/server.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type GameServer struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
IsEnabled bool `json:"is_enabled"`
|
||||||
|
StatusJSON *string `json:"-"`
|
||||||
|
LastPolledAt *time.Time `json:"last_polled_at"`
|
||||||
|
Motd *string `json:"motd"`
|
||||||
|
PlayerCount *int `json:"player_count"`
|
||||||
|
MaxPlayers *int `json:"max_players"`
|
||||||
|
VersionName *string `json:"version_name"`
|
||||||
|
PingBackendServer *int `json:"ping_proxy_server"`
|
||||||
|
BlueMapURL *string `json:"bluemap_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerStatus struct {
|
||||||
|
StatusJSON string
|
||||||
|
Motd string
|
||||||
|
PlayerCount int
|
||||||
|
MaxPlayers int
|
||||||
|
VersionName string
|
||||||
|
Ping int64
|
||||||
|
}
|
||||||
34
internal/models/user.go
Normal file
34
internal/models/user.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User представляет структуру пользователя в таблице 'users'
|
||||||
|
type User struct {
|
||||||
|
ID int `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" validate:"required,min=3,max=16,alphanum"`
|
||||||
|
Email string `json:"email" validate:"required,email"`
|
||||||
|
Password string `json:"password" validate:"required,min=8"`
|
||||||
|
}
|
||||||
|
type Profile struct {
|
||||||
|
ID int `json:"-"`
|
||||||
|
UserID int `json:"-"`
|
||||||
|
SkinHash string `json:"skin_hash,omitempty"`
|
||||||
|
CapeHash string `json:"cape_hash,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
47
internal/utils/validator.go
Normal file
47
internal/utils/validator.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validate *validator.Validate
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
validate = validator.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationErrorResponse represents the structure of validation errors returned to the client
|
||||||
|
type ValidationErrorResponse struct {
|
||||||
|
Errors map[string]string `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStruct validates a struct based on its tags using go-playground/validator
|
||||||
|
func ValidateStruct(s interface{}) *ValidationErrorResponse {
|
||||||
|
err := validate.Struct(s)
|
||||||
|
if err != nil {
|
||||||
|
var errorsMap = make(map[string]string)
|
||||||
|
for _, err := range err.(validator.ValidationErrors) {
|
||||||
|
// Simpler error messages for now. Can be enhanced with universal-translator.
|
||||||
|
fieldName := strings.ToLower(err.Field())
|
||||||
|
switch err.Tag() {
|
||||||
|
case "required":
|
||||||
|
errorsMap[fieldName] = fmt.Sprintf("Field '%s' is required", fieldName)
|
||||||
|
case "email":
|
||||||
|
errorsMap[fieldName] = fmt.Sprintf("Field '%s' must be a valid email", fieldName)
|
||||||
|
case "min":
|
||||||
|
errorsMap[fieldName] = fmt.Sprintf("Field '%s' must be at least %s characters long", fieldName, err.Param())
|
||||||
|
case "max":
|
||||||
|
errorsMap[fieldName] = fmt.Sprintf("Field '%s' must be at most %s characters long", fieldName, err.Param())
|
||||||
|
case "alphanum":
|
||||||
|
errorsMap[fieldName] = fmt.Sprintf("Field '%s' must contain only alphanumeric characters", fieldName)
|
||||||
|
default:
|
||||||
|
errorsMap[fieldName] = fmt.Sprintf("Field '%s' failed validation on '%s' tag", fieldName, err.Tag())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ValidationErrorResponse{Errors: errorsMap}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
179
internal/ws/hub.go
Normal file
179
internal/ws/hub.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Time allowed to write a message to the peer.
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer.
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
|
||||||
|
// Send pings to peer with this period. Must be less than pongWait.
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
// Разрешаем CORS для разработки (в продакшене лучше ограничить)
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub maintains the set of active clients and broadcasts messages to the clients.
|
||||||
|
type Hub struct {
|
||||||
|
// Registered clients.
|
||||||
|
clients map[*Client]bool
|
||||||
|
|
||||||
|
// Inbound messages from the clients (not used for now, only broadcast).
|
||||||
|
broadcast chan []byte
|
||||||
|
|
||||||
|
// Register requests from the clients.
|
||||||
|
register chan *Client
|
||||||
|
|
||||||
|
// Unregister requests from clients.
|
||||||
|
unregister chan *Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
broadcast: make(chan []byte),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-h.register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[client] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
slog.Debug("WS: Client registered")
|
||||||
|
|
||||||
|
case client := <-h.unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[client]; ok {
|
||||||
|
delete(h.clients, client)
|
||||||
|
close(client.send)
|
||||||
|
slog.Debug("WS: Client unregistered")
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
h.mu.Lock()
|
||||||
|
for client := range h.clients {
|
||||||
|
select {
|
||||||
|
case client.send <- message:
|
||||||
|
default:
|
||||||
|
close(client.send)
|
||||||
|
delete(h.clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast отправляет сообщение всем подключенным клиентам
|
||||||
|
func (h *Hub) BroadcastMessage(msg []byte) {
|
||||||
|
h.broadcast <- msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a middleman between the websocket connection and the hub.
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
|
||||||
|
// The websocket connection.
|
||||||
|
conn *websocket.Conn
|
||||||
|
|
||||||
|
// Buffered channel of outbound messages.
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump pumps messages from the hub to the websocket connection.
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.send:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
// The hub closed the channel.
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := c.conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(message)
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump pumps messages from the websocket connection to the hub.
|
||||||
|
// (Needed to process PONGs and detect disconnects)
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.unregister <- c
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
c.conn.SetReadLimit(512)
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
|
||||||
|
for {
|
||||||
|
_, _, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
slog.Error("WS: error", "error", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeWs handles websocket requests from the peer.
|
||||||
|
func ServeWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("WS: Failed to upgrade connection", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)}
|
||||||
|
client.hub.register <- client
|
||||||
|
|
||||||
|
// Allow collection of memory referenced by the caller by doing all work in
|
||||||
|
// new goroutines.
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user