From 9c7940a70a0ece77bfbfc58e35769b30aa3fc67e Mon Sep 17 00:00:00 2001 From: Vladimir Zagainov Date: Mon, 16 Jun 2025 07:20:09 +0300 Subject: [PATCH] feat(profile): implement protected skin upload endpoint --- cmd/server/main.go | 21 +++++---- go.mod | 1 + go.sum | 2 + internal/api/middleware.go | 69 ++++++++++++++++++++++++++++ internal/api/profile_handler.go | 29 ++++++++++++ internal/core/profile_service.go | 54 ++++++++++++++++++++++ internal/database/user_repository.go | 19 ++++++++ 7 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 internal/api/middleware.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 2a5a2a2..b031e62 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,25 +47,28 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) - // Группа маршрутов для Web API + // --- Публичные роуты --- r.Route("/api", func(r chi.Router) { r.Post("/register", userHandler.Register) + // Здесь будет публичный эндпоинт для логина в веб-интерфейс }) - - // Группа маршрутов для Yggdrasil API r.Route("/authserver", func(r chi.Router) { r.Post("/authenticate", authHandler.Authenticate) - // Здесь будут другие эндпоинты: refresh, validate, signout, invalidate }) - // Группа маршрутов для Session Server API r.Route("/sessionserver/session/minecraft", func(r chi.Router) { - r.Post("/join", authHandler.Join) // <-- ДОБАВЛЯЕМ ЭТОТ МАРШРУТ + r.Post("/join", authHandler.Join) r.Get("/profile/{uuid}", profileHandler.GetProfile) }) - // Маршрут для проверки, что сервер жив - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Backend server is running!")) + // --- Защищенные роуты --- + r.Group(func(r chi.Router) { + // Применяем нашу middleware ко всем роутам в этой группе + r.Use(api.AuthMiddleware) + + r.Route("/api/user", func(r chi.Router) { + r.Post("/skin", profileHandler.UploadSkin) + // Здесь будут другие эндпоинты для управления профилем + }) }) log.Println("Starting backend server on :8080") diff --git a/go.mod b/go.mod index 8f671ea..e9e474c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.1 require ( github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 81460b7..3afc06c 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..331069c --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,69 @@ +package api + +import ( + "context" + "net/http" + "os" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// contextKey - это тип для ключей контекста, чтобы избежать коллизий. +type contextKey string + +const UserIDContextKey = contextKey("userID") + +// AuthMiddleware проверяет JWT токен и добавляет user_id в контекст запроса. +func AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + http.Error(w, "Authorization header required", http.StatusUnauthorized) + return + } + + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == authHeader { // Префикс "Bearer " не найден + http.Error(w, "Invalid token format", http.StatusUnauthorized) + return + } + + jwtSecret := []byte(os.Getenv("JWT_SECRET_KEY")) + if len(jwtSecret) == 0 { + http.Error(w, "JWT secret not configured", http.StatusInternalServerError) + return + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Проверяем, что метод подписи HMAC + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, jwt.ErrSignatureInvalid + } + return jwtSecret, nil + }) + + if err != nil || !token.Valid { + http.Error(w, "Invalid or expired token", http.StatusUnauthorized) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, "Invalid token claims", http.StatusUnauthorized) + return + } + + // Получаем user_id из claims. JWT хранит числа как float64. + userIDFloat, ok := claims["user_id"].(float64) + if !ok { + http.Error(w, "Invalid user_id in token", http.StatusUnauthorized) + return + } + userID := int(userIDFloat) + + // Добавляем userID в контекст запроса для использования в хендлере + ctx := context.WithValue(r.Context(), UserIDContextKey, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/api/profile_handler.go b/internal/api/profile_handler.go index 2ff1539..32ee97e 100644 --- a/internal/api/profile_handler.go +++ b/internal/api/profile_handler.go @@ -38,3 +38,32 @@ func (h *ProfileHandler) GetProfile(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(profile) } + +func (h *ProfileHandler) UploadSkin(w http.ResponseWriter, r *http.Request) { + // Получаем userID из контекста, который был добавлен middleware + userID, ok := r.Context().Value(UserIDContextKey).(int) + if !ok { + http.Error(w, "Could not get user ID from context", http.StatusInternalServerError) + return + } + + // Ограничиваем размер загружаемого файла (например, 16KB) + r.ParseMultipartForm(16 << 10) // 16KB + + file, header, err := r.FormFile("skin") // "skin" - это имя поля в форме + if err != nil { + http.Error(w, "Invalid file upload", http.StatusBadRequest) + return + } + defer file.Close() + + err = h.Service.UpdateUserSkin(r.Context(), userID, file, header) + if err != nil { + // Можно добавить более детальную обработку ошибок + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Skin updated successfully")) +} diff --git a/internal/core/profile_service.go b/internal/core/profile_service.go index 9b96367..a8cb69e 100644 --- a/internal/core/profile_service.go +++ b/internal/core/profile_service.go @@ -8,10 +8,14 @@ import ( "crypto/sha1" "crypto/x509" "encoding/base64" + "encoding/hex" // Для преобразования хеша в строку "encoding/json" "encoding/pem" "errors" "fmt" + "image/png" // Для валидации PNG + "io" + "mime/multipart" "os" "strings" "time" @@ -117,3 +121,53 @@ func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.U return response, nil } + +// UpdateUserSkin обрабатывает загрузку, валидацию и сохранение файла скина. +func (s *ProfileService) UpdateUserSkin(ctx context.Context, userID int, file multipart.File, header *multipart.FileHeader) error { + // 1. Читаем файл в память + fileBytes, err := io.ReadAll(file) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + // Возвращаем указатель файла в начало, чтобы его можно было прочитать снова + file.Seek(0, 0) + + // 2. Валидация PNG 64x64 + config, err := png.DecodeConfig(file) + if err != nil { + return errors.New("invalid PNG file") + } + if config.Width != 64 || config.Height != 64 { + return errors.New("skin must be 64x64 pixels") + } + + // 3. Вычисляем SHA1 хеш + hasher := sha1.New() + hasher.Write(fileBytes) + hash := hex.EncodeToString(hasher.Sum(nil)) + + // 4. Сохраняем файл на диск + // Путь к хранилищу текстур должен быть конфигурируемым + storagePath := os.Getenv("TEXTURES_STORAGE_PATH") + if storagePath == "" { + return errors.New("textures storage path not configured") + } + filePath := fmt.Sprintf("%s/%s", storagePath, hash) + + // Создаем файл только если его еще нет + if _, err := os.Stat(filePath); os.IsNotExist(err) { + outFile, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create skin file: %w", err) + } + defer outFile.Close() + + _, err = outFile.Write(fileBytes) + if err != nil { + return fmt.Errorf("failed to write skin file: %w", err) + } + } + + // 5. Сохраняем хеш в БД + return s.UserRepo.UpdateSkinHash(ctx, userID, hash) +} diff --git a/internal/database/user_repository.go b/internal/database/user_repository.go index c99eb71..e96d053 100644 --- a/internal/database/user_repository.go +++ b/internal/database/user_repository.go @@ -157,3 +157,22 @@ func (r *UserRepository) ValidateAccessToken(ctx context.Context, token string, return nil } + +// UpdateSkinHash обновляет хеш скина для пользователя. +func (r *UserRepository) UpdateSkinHash(ctx context.Context, userID int, skinHash string) error { + query := "UPDATE profiles SET skin_hash = $1, updated_at = NOW() WHERE user_id = $2" + result, err := r.DB.ExecContext(ctx, query, skinHash, userID) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return ErrUserNotFound // Если профиль для user_id не найден + } + + return nil +}