Compare commits

...

12 Commits

22 changed files with 1511 additions and 137 deletions

View File

@@ -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
# Копируем весь остальной исходный код # Копируем весь остальной исходный код

View File

@@ -2,19 +2,30 @@ package main
import ( import (
"context" "context"
"log" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal"
"syscall"
"time"
"gitea.mrixs.me/minecraft-platform/backend/internal/api" "gitea.mrixs.me/minecraft-platform/backend/internal/api"
"gitea.mrixs.me/minecraft-platform/backend/internal/core" "gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/database" "gitea.mrixs.me/minecraft-platform/backend/internal/database"
"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) ---
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
slog.Info("Starting backend server initialization...")
dbPool := database.Connect() dbPool := database.Connect()
defer dbPool.Close() defer dbPool.Close()
@@ -22,33 +33,45 @@ func main() {
userRepo := &database.UserRepository{DB: dbPool} userRepo := &database.UserRepository{DB: dbPool}
serverRepo := &database.ServerRepository{DB: dbPool} serverRepo := &database.ServerRepository{DB: dbPool}
modpackRepo := &database.ModpackRepository{DB: dbPool} modpackRepo := &database.ModpackRepository{DB: dbPool}
jobRepo := &database.JobRepository{DB: dbPool}
// --- Инициализация сервисов --- // --- Инициализация сервисов ---
userService := &core.UserService{Repo: userRepo} userService := &core.UserService{Repo: userRepo}
authService := &core.AuthService{UserRepo: userRepo} authService := &core.AuthService{UserRepo: userRepo}
serverPoller := &core.ServerPoller{Repo: serverRepo} serverPoller := &core.ServerPoller{Repo: serverRepo}
// --- Инициализация WebSocket Hub ---
hub := ws.NewHub()
go hub.Run()
keyPath := os.Getenv("RSA_PRIVATE_KEY_PATH") keyPath := os.Getenv("RSA_PRIVATE_KEY_PATH")
if keyPath == "" { if keyPath == "" {
log.Fatal("RSA_PRIVATE_KEY_PATH environment variable is not set") slog.Error("RSA_PRIVATE_KEY_PATH environment variable is not set")
os.Exit(1)
} }
domain := os.Getenv("APP_DOMAIN") domain := os.Getenv("APP_DOMAIN")
if domain == "" { if domain == "" {
log.Fatal("APP_DOMAIN environment variable is not set") slog.Error("APP_DOMAIN environment variable is not set")
os.Exit(1)
} }
profileService, err := core.NewProfileService(userRepo, keyPath, domain) profileService, err := core.NewProfileService(userRepo, keyPath, domain)
if err != nil { if err != nil {
log.Fatalf("Failed to create profile service: %v", err) slog.Error("Failed to create profile service", "error", err)
os.Exit(1)
} }
modpacksStoragePath := os.Getenv("MODPACKS_STORAGE_PATH") modpacksStoragePath := os.Getenv("MODPACKS_STORAGE_PATH")
if modpacksStoragePath == "" { if modpacksStoragePath == "" {
log.Fatal("MODPACKS_STORAGE_PATH environment variable is not set") slog.Error("MODPACKS_STORAGE_PATH environment variable is not set")
os.Exit(1)
} }
janitorService := core.NewFileJanitorService(modpackRepo, modpacksStoragePath) janitorService := core.NewFileJanitorService(modpackRepo, modpacksStoragePath)
// --- Запуск фоновых задач --- // --- Запуск фоновых задач ---
go serverPoller.Start(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go serverPoller.Start(ctx) // Передаем контекст для отмены
// --- Инициализация хендлеров --- // --- Инициализация хендлеров ---
userHandler := &api.UserHandler{Service: userService} userHandler := &api.UserHandler{Service: userService}
@@ -58,19 +81,35 @@ func main() {
launcherHandler := &api.LauncherHandler{ModpackRepo: modpackRepo} launcherHandler := &api.LauncherHandler{ModpackRepo: modpackRepo}
modpackHandler := &api.ModpackHandler{ modpackHandler := &api.ModpackHandler{
ModpackRepo: modpackRepo, ModpackRepo: modpackRepo,
JobRepo: jobRepo,
JanitorService: janitorService, JanitorService: janitorService,
Hub: hub,
} }
adminUserHandler := &api.AdminUserHandler{UserRepo: userRepo} // Этот хендлер мы создали для админских функций adminUserHandler := &api.AdminUserHandler{UserRepo: userRepo}
// --- Настройка роутера --- // --- Настройка роутера ---
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger) // Можно заменить на slog middleware, но пока оставим standard
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
// Health Check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// --- Публичные роуты --- // --- Публичные роуты ---
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
// Rate limiting: 100 requests per minute for general API
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("/register", userHandler.Register)
r.Post("/login", authHandler.Login) r.Post("/login", authHandler.Login)
})
r.Get("/servers", serverHandler.GetServers) r.Get("/servers", serverHandler.GetServers)
r.Route("/launcher", func(r chi.Router) { r.Route("/launcher", func(r chi.Router) {
@@ -79,9 +118,13 @@ func main() {
}) })
}) })
r.Route("/authserver", func(r chi.Router) { 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.Post("/authenticate", authHandler.Authenticate)
}) })
r.Route("/sessionserver/session/minecraft", func(r chi.Router) { 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.Post("/join", authHandler.Join)
r.Get("/profile/{uuid}", profileHandler.GetProfile) r.Get("/profile/{uuid}", profileHandler.GetProfile)
}) })
@@ -98,20 +141,53 @@ func main() {
r.Route("/api/admin", func(r chi.Router) { r.Route("/api/admin", func(r chi.Router) {
r.Use(api.AdminMiddleware) 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.Route("/modpacks", func(r chi.Router) {
r.Get("/", modpackHandler.GetModpacks)
r.Post("/import", modpackHandler.ImportModpack) r.Post("/import", modpackHandler.ImportModpack)
r.Post("/update", modpackHandler.UpdateModpack)
r.Get("/versions", modpackHandler.GetModpackVersions)
}) })
r.Route("/users", func(r chi.Router) { r.Route("/users", func(r chi.Router) {
// ИСПРАВЛЕНО: Используем adminUserHandler
r.Get("/", adminUserHandler.GetAllUsers) r.Get("/", adminUserHandler.GetAllUsers)
r.Patch("/{id}/role", adminUserHandler.UpdateUserRole) r.Patch("/{id}/role", adminUserHandler.UpdateUserRole)
}) })
}) })
}) })
log.Println("Starting backend server on :8080") // --- Graceful Shutdown ---
if err := http.ListenAndServe(":8080", r); err != nil { srv := &http.Server{
log.Fatalf("Failed to start server: %v", err) 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")
} }

16
go.mod
View File

@@ -5,16 +5,26 @@ go 1.24.1
require ( require (
github.com/Tnze/go-mc v1.20.2 github.com/Tnze/go-mc v1.20.2
github.com/go-chi/chi/v5 v5.2.1 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/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.5 github.com/jackc/pgx/v5 v5.7.5
golang.org/x/crypto v0.39.0 golang.org/x/crypto v0.46.0
) )
require ( 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.15.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
golang.org/x/text v0.26.0 // 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
) )

38
go.sum
View File

@@ -3,12 +3,26 @@ github.com/Tnze/go-mc v1.20.2/go.mod h1:geoRj2HsXSkB3FJBuhr7wCzXegRlzWsVXd7h7jiJ
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -17,19 +31,27 @@ 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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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.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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -3,11 +3,12 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log/slog"
"net/http" "net/http"
"gitea.mrixs.me/minecraft-platform/backend/internal/core" "gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/models" "gitea.mrixs.me/minecraft-platform/backend/internal/models"
"gitea.mrixs.me/minecraft-platform/backend/internal/utils"
) )
type AuthHandler struct { type AuthHandler struct {
@@ -27,6 +28,13 @@ func (h *AuthHandler) Authenticate(w http.ResponseWriter, r *http.Request) {
return 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) response, err := h.Service.Authenticate(r.Context(), req)
if err != nil { if err != nil {
if errors.Is(err, core.ErrInvalidCredentials) { if errors.Is(err, core.ErrInvalidCredentials) {
@@ -40,7 +48,7 @@ func (h *AuthHandler) Authenticate(w http.ResponseWriter, r *http.Request) {
} }
// Другие ошибки - внутренние // Другие ошибки - внутренние
log.Printf("internal server error during authentication: %v", err) slog.Error("internal server error during authentication", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -57,6 +65,13 @@ func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) {
return 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) err := h.Service.ValidateJoinRequest(r.Context(), req)
if err != nil { if err != nil {
if errors.Is(err, core.ErrInvalidCredentials) { if errors.Is(err, core.ErrInvalidCredentials) {
@@ -69,7 +84,7 @@ func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("internal server error during join: %v", err) slog.Error("internal server error during join", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }
@@ -84,13 +99,20 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
return 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) token, user, err := h.Service.LoginUser(r.Context(), req)
if err != nil { if err != nil {
if errors.Is(err, core.ErrInvalidCredentials) { if errors.Is(err, core.ErrInvalidCredentials) {
http.Error(w, "Invalid username or password", http.StatusUnauthorized) http.Error(w, "Invalid username or password", http.StatusUnauthorized)
return return
} }
log.Printf("internal server error during login: %v", err) slog.Error("internal server error during login", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
return return
} }

View File

@@ -16,15 +16,25 @@ const ClaimsContextKey = contextKey("claims")
// AuthMiddleware проверяет JWT токен и добавляет claims в контекст запроса. // AuthMiddleware проверяет JWT токен и добавляет claims в контекст запроса.
func AuthMiddleware(next http.Handler) http.Handler { func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenString string
// 1. Проверяем заголовок Authorization
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if authHeader == "" { if authHeader != "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized) tokenString = strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader { // Не было префикса Bearer
http.Error(w, "Invalid token format", http.StatusUnauthorized)
return return
} }
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ") // 2. Если заголовка нет, проверяем параметр query (для WebSocket)
if tokenString == authHeader { if tokenString == "" {
http.Error(w, "Invalid token format", http.StatusUnauthorized) tokenString = r.URL.Query().Get("token")
}
if tokenString == "" {
http.Error(w, "Authorization required", http.StatusUnauthorized)
return return
} }

View File

@@ -2,59 +2,64 @@ package api
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"path"
"gitea.mrixs.me/minecraft-platform/backend/internal/core" "gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/core/importer" "gitea.mrixs.me/minecraft-platform/backend/internal/core/importer"
"gitea.mrixs.me/minecraft-platform/backend/internal/database" "gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models" "gitea.mrixs.me/minecraft-platform/backend/internal/models"
"gitea.mrixs.me/minecraft-platform/backend/internal/ws"
) )
type ModpackHandler struct { type ModpackHandler struct {
ModpackRepo *database.ModpackRepository ModpackRepo *database.ModpackRepository
JobRepo *database.JobRepository
JanitorService *core.FileJanitorService JanitorService *core.FileJanitorService
Hub *ws.Hub
} }
// ImportModpack обрабатывает загрузку и импорт модпака. // 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) { func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(512 << 20); err != nil { if err := r.ParseMultipartForm(512 << 20); err != nil {
http.Error(w, "File too large", http.StatusBadRequest) http.Error(w, "File too large", http.StatusBadRequest)
return return
} }
importerType := r.FormValue("importerType") params := ImportJobParams{
importMethod := r.FormValue("importMethod") ImporterType: r.FormValue("importerType"),
sourceURL := r.FormValue("sourceUrl") ImportMethod: r.FormValue("importMethod"),
SourceURL: r.FormValue("sourceUrl"),
var tempZipPath string Name: r.FormValue("name"),
var err error DisplayName: r.FormValue("displayName"),
MCVersion: r.FormValue("mcVersion"),
// --- Выбираем импортер ---
var imp importer.ModpackImporter
storagePath := os.Getenv("MODPACKS_STORAGE_PATH")
switch importerType {
case "simple":
imp = &importer.SimpleZipImporter{StoragePath: storagePath}
case "curseforge":
apiKey := os.Getenv("CURSEFORGE_API_KEY")
if apiKey == "" {
http.Error(w, "CurseForge API key is not configured on the server", http.StatusInternalServerError)
return
} }
imp = importer.NewCurseForgeImporter(storagePath, apiKey)
case "modrinth": // Валидация
imp = importer.NewModrinthImporter(storagePath) if params.Name == "" || params.DisplayName == "" || params.MCVersion == "" {
default: http.Error(w, "Missing required fields", http.StatusBadRequest)
http.Error(w, "Invalid importer type", http.StatusBadRequest)
return return
} }
// --- Получаем zip-файл --- // Обработка загрузки файла
if importMethod == "file" { if params.ImportMethod == "file" {
file, _, err := r.FormFile("file") file, _, err := r.FormFile("file")
if err != nil { if err != nil {
http.Error(w, "Invalid file upload", http.StatusBadRequest) http.Error(w, "Invalid file upload", http.StatusBadRequest)
@@ -67,55 +72,389 @@ func (h *ModpackHandler) ImportModpack(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Could not create temp file", http.StatusInternalServerError) http.Error(w, "Could not create temp file", http.StatusInternalServerError)
return return
} }
defer tempFile.Close() // Не удаляем файл здесь, так как он нужен worker-у. Удалим в worker-е.
defer os.Remove(tempFile.Name()) // defer os.Remove(tempFile.Name())
if _, err := io.Copy(tempFile, file); err != nil { if _, err := io.Copy(tempFile, file); err != nil {
tempFile.Close()
os.Remove(tempFile.Name())
http.Error(w, "Could not save temp file", http.StatusInternalServerError) http.Error(w, "Could not save temp file", http.StatusInternalServerError)
return return
} }
tempZipPath = tempFile.Name() tempFile.Close()
params.TempZipPath = tempFile.Name()
} else if importMethod == "url" {
cfImporter, ok := imp.(*importer.CurseForgeImporter)
if !ok {
http.Error(w, "Importer type does not support URL import", http.StatusBadRequest)
return
} }
tempZipPath, err = cfImporter.DownloadModpackFromURL(sourceURL)
// Создаем задачу в БД
jobID, err := h.JobRepo.CreateJob(r.Context())
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Failed to download from URL: %v", err), http.StatusInternalServerError) if params.ImportMethod == "file" {
os.Remove(params.TempZipPath)
}
http.Error(w, fmt.Sprintf("Failed to create job: %v", err), http.StatusInternalServerError)
return return
} }
defer os.Remove(tempZipPath)
// Запускаем 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 { } else {
http.Error(w, "Invalid import method", http.StatusBadRequest) // Для других типов пока не поддерживаем URL download внутри импортера
h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, "URL import not supported for this type")
return return
} }
}
// --- Запускаем импорт --- h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 40, "Processing modpack files...")
files, err := imp.Import(tempZipPath)
// Импорт файлов
files, err := imp.Import(params.TempZipPath)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Import failed: %v", err), http.StatusInternalServerError) h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Import failed: %v", err))
return return
} }
// --- Сохраняем результат в БД --- h.updateJobStatus(ctx, jobID, models.JobStatusProcessing, 80, "Saving to database...")
// Сохранение в БД
modpack := &models.Modpack{ modpack := &models.Modpack{
Name: r.FormValue("name"), Name: params.Name,
DisplayName: r.FormValue("displayName"), DisplayName: params.DisplayName,
MinecraftVersion: r.FormValue("mcVersion"), MinecraftVersion: params.MCVersion,
} }
err = h.ModpackRepo.CreateModpackTx(r.Context(), modpack, files) err = h.ModpackRepo.CreateModpackTx(ctx, modpack, files)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Database save failed: %v", err), http.StatusInternalServerError) h.updateJobStatus(ctx, jobID, models.JobStatusFailed, 0, fmt.Sprintf("Database save failed: %v", err))
return return
} }
w.WriteHeader(http.StatusCreated) h.updateJobStatus(ctx, jobID, models.JobStatusCompleted, 100, "Success")
fmt.Fprintf(w, "Modpack '%s' imported successfully with %d files.", modpack.DisplayName, len(files))
// Запуск 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()) go h.JanitorService.CleanOrphanedFiles(context.Background())
} }

480
internal/api/openapi.yaml Normal file
View 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

View File

@@ -4,11 +4,13 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log"
"log/slog"
"net/http" "net/http"
"gitea.mrixs.me/minecraft-platform/backend/internal/core" "gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/database" "gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models" "gitea.mrixs.me/minecraft-platform/backend/internal/models"
"gitea.mrixs.me/minecraft-platform/backend/internal/utils"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
@@ -23,13 +25,18 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
// --- ДОБАВЛЕНО ЛОГИРОВАНИЕ --- if validationErrors := utils.ValidateStruct(req); validationErrors != nil {
log.Printf("[Handler] Received registration request for username: '%s', email: '%s'", req.Username, req.Email) 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) err := h.Service.RegisterNewUser(r.Context(), req)
if err != nil { if err != nil {
// --- ДОБАВЛЕНО ЛОГИРОВАНИЕ ОШИБКИ --- slog.Error("Service returned error", "error", err)
log.Printf("[Handler] Service returned error: %v", err)
switch { switch {
case errors.Is(err, database.ErrUserExists): case errors.Is(err, database.ErrUserExists):
@@ -42,8 +49,7 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
return return
} }
// --- ДОБАВЛЕНО ЛОГИРОВАНИЕ УСПЕХА --- slog.Info("User registered successfully", "username", req.Username)
log.Printf("[Handler] User '%s' registered successfully.", req.Username)
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }

View File

@@ -84,8 +84,8 @@ func (i *CurseForgeImporter) getFileInfo(projectID, fileID int) (*CurseForgeFile
return &fileInfo, nil return &fileInfo, nil
} }
// findModpackBySlug ищет ID проекта по его "слагу" (части URL). // FindModpackBySlug ищет ID проекта по его "слагу" (части URL).
func (i *CurseForgeImporter) findModpackBySlug(slug string) (int, error) { 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)) 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) req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil)
if err != nil { if err != nil {
@@ -113,30 +113,40 @@ func (i *CurseForgeImporter) findModpackBySlug(slug string) (int, error) {
// getLatestModpackFileURL находит URL для скачивания последнего файла проекта. // getLatestModpackFileURL находит URL для скачивания последнего файла проекта.
func (i *CurseForgeImporter) getLatestModpackFileURL(projectID int) (string, error) { 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) apiURL := fmt.Sprintf("https://api.curseforge.com/v1/mods/%d/files", projectID)
req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil) req, err := http.NewRequestWithContext(context.Background(), "GET", apiURL, nil)
if err != nil { if err != nil {
return "", err return nil, err
} }
req.Header.Set("x-api-key", i.APIKey) req.Header.Set("x-api-key", i.APIKey)
resp, err := i.HTTPClient.Do(req) resp, err := i.HTTPClient.Do(req)
if err != nil { if err != nil {
return "", err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
var filesResp CurseForgeFilesResponse var filesResp CurseForgeFilesResponse
if err := json.NewDecoder(resp.Body).Decode(&filesResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&filesResp); err != nil {
return "", err return nil, err
} }
if len(filesResp.Data) == 0 { return filesResp.Data, nil
return "", fmt.Errorf("no files found for projectID %d", projectID)
}
latestFile := filesResp.Data[0]
return latestFile.DownloadURL, nil
} }
// DownloadModpackFromURL скачивает zip-архив модпака по URL страницы CurseForge. // DownloadModpackFromURL скачивает zip-архив модпака по URL страницы CurseForge.
@@ -148,7 +158,7 @@ func (i *CurseForgeImporter) DownloadModpackFromURL(pageURL string) (string, err
slug := path.Base(parsedURL.Path) slug := path.Base(parsedURL.Path)
log.Printf("Importer: Extracted slug '%s' from URL", slug) log.Printf("Importer: Extracted slug '%s' from URL", slug)
projectID, err := i.findModpackBySlug(slug) projectID, err := i.FindModpackBySlug(slug)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@@ -31,16 +31,22 @@ type CurseForgeSearchResponse struct {
// CurseForgeFilesResponse - ответ от эндпоинта получения файлов проекта // CurseForgeFilesResponse - ответ от эндпоинта получения файлов проекта
type CurseForgeFilesResponse struct { type CurseForgeFilesResponse struct {
Data []struct { Data []CurseForgeFileData `json:"data"`
ID int `json:"id"`
FileName string `json:"fileName"`
DownloadURL string `json:"downloadUrl"`
} `json:"data"`
Pagination struct { Pagination struct {
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
} `json:"pagination"` } `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. // CurseForgeFile представляет полную информацию о файле с API.
type CurseForgeFile struct { type CurseForgeFile struct {
Data struct { Data struct {

View File

@@ -3,7 +3,7 @@ package core
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"log" "log/slog"
"time" "time"
"gitea.mrixs.me/minecraft-platform/backend/internal/database" "gitea.mrixs.me/minecraft-platform/backend/internal/database"
@@ -29,7 +29,7 @@ type ServerPoller struct {
} }
func (p *ServerPoller) Start(ctx context.Context) { func (p *ServerPoller) Start(ctx context.Context) {
log.Println("Starting server poller...") slog.Info("Starting server poller...")
ticker := time.NewTicker(60 * time.Second) ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -40,7 +40,7 @@ func (p *ServerPoller) Start(ctx context.Context) {
case <-ticker.C: case <-ticker.C:
p.pollAllServers(ctx) p.pollAllServers(ctx)
case <-ctx.Done(): case <-ctx.Done():
log.Println("Stopping server poller...") slog.Info("Stopping server poller...")
return return
} }
} }
@@ -49,7 +49,7 @@ func (p *ServerPoller) Start(ctx context.Context) {
func (p *ServerPoller) pollAllServers(ctx context.Context) { func (p *ServerPoller) pollAllServers(ctx context.Context) {
servers, err := p.Repo.GetAllEnabledServers(ctx) servers, err := p.Repo.GetAllEnabledServers(ctx)
if err != nil { if err != nil {
log.Printf("Poller: failed to get servers: %v", err) slog.Error("Poller: failed to get servers", "error", err)
return return
} }
@@ -61,13 +61,13 @@ func (p *ServerPoller) pollAllServers(ctx context.Context) {
func (p *ServerPoller) pollServer(ctx context.Context, server *models.GameServer) { func (p *ServerPoller) pollServer(ctx context.Context, server *models.GameServer) {
resp, delay, err := bot.PingAndList(server.Address) resp, delay, err := bot.PingAndList(server.Address)
if err != nil { if err != nil {
log.Printf("Poller: failed to ping %s (%s): %v", server.Name, server.Address, err) slog.Warn("Poller: failed to ping server", "server", server.Name, "address", server.Address, "error", err)
return return
} }
var status pingResponse var status pingResponse
if err := json.Unmarshal(resp, &status); err != nil { if err := json.Unmarshal(resp, &status); err != nil {
log.Printf("Poller: failed to unmarshal status for %s: %v", server.Name, err) slog.Error("Poller: failed to unmarshal status", "server", server.Name, "error", err)
return return
} }
@@ -83,8 +83,8 @@ func (p *ServerPoller) pollServer(ctx context.Context, server *models.GameServer
} }
if err := p.Repo.UpdateServerStatus(ctx, server.ID, updateData); err != nil { if err := p.Repo.UpdateServerStatus(ctx, server.ID, updateData); err != nil {
log.Printf("Poller: failed to update status for %s: %v", server.Name, err) slog.Error("Poller: failed to update status", "server", server.Name, "error", err)
} else { } else {
log.Printf("Poller: successfully polled %s", server.Name) slog.Info("Poller: successfully polled server", "server", server.Name)
} }
} }

View 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
}

View File

@@ -126,3 +126,85 @@ func (r *ModpackRepository) GetModpacksSummary(ctx context.Context) ([]models.Mo
return summaries, nil 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
}

View File

@@ -47,7 +47,7 @@ func (r *ServerRepository) UpdateServerStatus(ctx context.Context, id int, statu
func (r *ServerRepository) GetAllWithStatus(ctx context.Context) ([]*models.GameServer, error) { func (r *ServerRepository) GetAllWithStatus(ctx context.Context) ([]*models.GameServer, error) {
query := ` query := `
SELECT id, name, address, is_enabled, last_polled_at, motd, SELECT id, name, address, is_enabled, last_polled_at, motd,
player_count, max_players, version_name, ping_backend_server player_count, max_players, version_name, ping_backend_server, bluemap_url
FROM game_servers WHERE is_enabled = TRUE ORDER BY name` FROM game_servers WHERE is_enabled = TRUE ORDER BY name`
rows, err := r.DB.Query(ctx, query) rows, err := r.DB.Query(ctx, query)
if err != nil { if err != nil {
@@ -59,7 +59,7 @@ func (r *ServerRepository) GetAllWithStatus(ctx context.Context) ([]*models.Game
for rows.Next() { for rows.Next() {
s := &models.GameServer{} s := &models.GameServer{}
if err := rows.Scan(&s.ID, &s.Name, &s.Address, &s.IsEnabled, &s.LastPolledAt, if err := rows.Scan(&s.ID, &s.Name, &s.Address, &s.IsEnabled, &s.LastPolledAt,
&s.Motd, &s.PlayerCount, &s.MaxPlayers, &s.VersionName, &s.PingBackendServer); err != nil { &s.Motd, &s.PlayerCount, &s.MaxPlayers, &s.VersionName, &s.PingBackendServer, &s.BlueMapURL); err != nil {
return nil, err return nil, err
} }
servers = append(servers, s) servers = append(servers, s)

View File

@@ -9,8 +9,8 @@ type Agent struct {
// AuthenticateRequest - это тело запроса на /authserver/authenticate // AuthenticateRequest - это тело запроса на /authserver/authenticate
type AuthenticateRequest struct { type AuthenticateRequest struct {
Agent Agent `json:"agent"` Agent Agent `json:"agent"`
Username string `json:"username"` Username string `json:"username" validate:"required"`
Password string `json:"password"` Password string `json:"password" validate:"required"`
ClientToken string `json:"clientToken"` ClientToken string `json:"clientToken"`
} }
@@ -70,15 +70,15 @@ type SessionProfileResponse struct {
// JoinRequest - это тело запроса на /sessionserver/session/minecraft/join // JoinRequest - это тело запроса на /sessionserver/session/minecraft/join
type JoinRequest struct { type JoinRequest struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken" validate:"required"`
SelectedProfile string `json:"selectedProfile"` // UUID пользователя без дефисов SelectedProfile string `json:"selectedProfile" validate:"required"` // UUID пользователя без дефисов
ServerID string `json:"serverId"` ServerID string `json:"serverId" validate:"required"`
} }
// LoginRequest - это тело запроса на /api/login // LoginRequest - это тело запроса на /api/login
type LoginRequest struct { type LoginRequest struct {
Login string `json:"login"` Login string `json:"login" validate:"required"`
Password string `json:"password"` Password string `json:"password" validate:"required"`
} }
// LoginResponse - это тело успешного ответа с JWT // LoginResponse - это тело успешного ответа с JWT

24
internal/models/job.go Normal file
View 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"`
}

View File

@@ -14,6 +14,7 @@ type GameServer struct {
MaxPlayers *int `json:"max_players"` MaxPlayers *int `json:"max_players"`
VersionName *string `json:"version_name"` VersionName *string `json:"version_name"`
PingBackendServer *int `json:"ping_proxy_server"` PingBackendServer *int `json:"ping_proxy_server"`
BlueMapURL *string `json:"bluemap_url"`
} }
type ServerStatus struct { type ServerStatus struct {

View File

@@ -20,9 +20,9 @@ type User struct {
// RegisterRequest определяет структуру JSON-запроса на регистрацию // RegisterRequest определяет структуру JSON-запроса на регистрацию
type RegisterRequest struct { type RegisterRequest struct {
Username string `json:"username"` Username string `json:"username" validate:"required,min=3,max=16,alphanum"`
Email string `json:"email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password"` Password string `json:"password" validate:"required,min=8"`
} }
type Profile struct { type Profile struct {
ID int `json:"-"` ID int `json:"-"`

View 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
View 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()
}

BIN
main Executable file

Binary file not shown.