Compare commits

..

10 Commits

17 changed files with 1005 additions and 49 deletions

View File

@@ -8,6 +8,8 @@ WORKDIR /app
# Копируем файлы go.mod и go.sum для загрузки зависимостей
COPY go.mod go.sum ./
# Загружаем зависимости. Этот слой будет кэшироваться, если файлы не менялись
ENV GOPROXY=direct
RUN apk add --no-cache git
RUN go mod download
# Копируем весь остальной исходный код

View File

@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/httprate"
)
func main() {
@@ -99,8 +100,16 @@ func main() {
// --- Публичные роуты ---
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("/login", authHandler.Login)
})
r.Get("/servers", serverHandler.GetServers)
r.Route("/launcher", func(r chi.Router) {
@@ -109,9 +118,13 @@ func main() {
})
})
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)
})
@@ -134,7 +147,10 @@ func main() {
})
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) {

15
go.mod
View File

@@ -5,17 +5,26 @@ go 1.24.1
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.39.0
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
golang.org/x/sync v0.15.0 // indirect
golang.org/x/text v0.26.0 // 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
)

36
go.sum
View File

@@ -3,8 +3,20 @@ 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.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/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=
@@ -19,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/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.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
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=

View File

@@ -8,6 +8,7 @@ import (
"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 {
@@ -27,6 +28,13 @@ func (h *AuthHandler) Authenticate(w http.ResponseWriter, r *http.Request) {
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) {
@@ -57,6 +65,13 @@ func (h *AuthHandler) Join(w http.ResponseWriter, r *http.Request) {
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) {
@@ -84,6 +99,13 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
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) {

View File

@@ -7,7 +7,9 @@ import (
"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"
@@ -203,3 +205,256 @@ func (h *ModpackHandler) updateJobStatus(ctx context.Context, jobID int, status
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
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"
"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"
)
@@ -23,13 +25,18 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
return
}
// --- ДОБАВЛЕНО ЛОГИРОВАНИЕ ---
log.Printf("[Handler] Received registration request for username: '%s', email: '%s'", req.Username, req.Email)
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 {
// --- ДОБАВЛЕНО ЛОГИРОВАНИЕ ОШИБКИ ---
log.Printf("[Handler] Service returned error: %v", err)
slog.Error("Service returned error", "error", err)
switch {
case errors.Is(err, database.ErrUserExists):
@@ -42,8 +49,7 @@ func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
return
}
// --- ДОБАВЛЕНО ЛОГИРОВАНИЕ УСПЕХА ---
log.Printf("[Handler] User '%s' registered successfully.", req.Username)
slog.Info("User registered successfully", "username", req.Username)
w.WriteHeader(http.StatusCreated)
}

View File

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

View File

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

View File

@@ -126,3 +126,85 @@ func (r *ModpackRepository) GetModpacksSummary(ctx context.Context) ([]models.Mo
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) {
query := `
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`
rows, err := r.DB.Query(ctx, query)
if err != nil {
@@ -59,7 +59,7 @@ func (r *ServerRepository) GetAllWithStatus(ctx context.Context) ([]*models.Game
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); err != nil {
&s.Motd, &s.PlayerCount, &s.MaxPlayers, &s.VersionName, &s.PingBackendServer, &s.BlueMapURL); err != nil {
return nil, err
}
servers = append(servers, s)

View File

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

View File

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

View File

@@ -20,9 +20,9 @@ type User struct {
// RegisterRequest определяет структуру JSON-запроса на регистрацию
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
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:"-"`

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
}

BIN
main Executable file

Binary file not shown.