Files
backend/internal/core/profile_service.go
2025-06-18 09:01:14 +03:00

161 lines
4.2 KiB
Go

package core
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"image/png"
"io"
"mime/multipart"
"os"
"strings"
"time"
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
"github.com/google/uuid"
)
type ProfileService struct {
UserRepo *database.UserRepository
privateKey *rsa.PrivateKey
domain string
}
// NewProfileService создает новый сервис и загружает приватный ключ.
func NewProfileService(repo *database.UserRepository, keyPath, domain string) (*ProfileService, error) {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file: %w", err)
}
block, _ := pem.Decode(keyData)
if block == nil {
return nil, errors.New("failed to parse PEM block containing the key")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes)
if errPkcs8 != nil {
return nil, fmt.Errorf("failed to parse private key: %v / %v", err, errPkcs8)
}
var ok bool
privateKey, ok = key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("key is not an RSA private key")
}
}
return &ProfileService{
UserRepo: repo,
privateKey: privateKey,
domain: domain,
}, nil
}
// GetSignedProfile формирует и подписывает профиль игрока.
func (s *ProfileService) GetSignedProfile(ctx context.Context, playerUUID uuid.UUID) (*models.SessionProfileResponse, error) {
user, profile, err := s.UserRepo.GetProfileByUUID(ctx, playerUUID)
if err != nil {
return nil, err
}
textures := models.Textures{}
if profile.SkinHash != "" {
textures.SKIN = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.SkinHash)}
}
if profile.CapeHash != "" {
textures.CAPE = &models.TextureInfo{URL: fmt.Sprintf("http://%s/files/textures/%s", s.domain, profile.CapeHash)}
}
profileID := strings.ReplaceAll(user.UUID.String(), "-", "")
propValue := models.ProfilePropertyValue{
Timestamp: time.Now().UnixMilli(),
ProfileID: profileID,
ProfileName: user.Username,
Textures: textures,
}
valueJSON, err := json.Marshal(propValue)
if err != nil {
return nil, err
}
valueBase64 := base64.StdEncoding.EncodeToString(valueJSON)
hasher := sha1.New()
hasher.Write([]byte(valueBase64))
hashed := hasher.Sum(nil)
signature, err := rsa.SignPKCS1v15(rand.Reader, s.privateKey, crypto.SHA1, hashed)
if err != nil {
return nil, err
}
signatureBase64 := base64.StdEncoding.EncodeToString(signature)
response := &models.SessionProfileResponse{
ID: profileID,
Name: user.Username,
Properties: []models.ProfileProperty{
{
Name: "textures",
Value: valueBase64,
Signature: signatureBase64,
},
},
}
return response, nil
}
// UpdateUserSkin обрабатывает загрузку, валидацию и сохранение файла скина.
func (s *ProfileService) UpdateUserSkin(ctx context.Context, userID int, file multipart.File, header *multipart.FileHeader) error {
fileBytes, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
file.Seek(0, 0)
config, err := png.DecodeConfig(file)
if err != nil {
return errors.New("invalid PNG file")
}
if config.Width != 64 || config.Height != 64 {
return errors.New("skin must be 64x64 pixels")
}
hasher := sha1.New()
hasher.Write(fileBytes)
hash := hex.EncodeToString(hasher.Sum(nil))
storagePath := os.Getenv("TEXTURES_STORAGE_PATH")
if storagePath == "" {
return errors.New("textures storage path not configured")
}
filePath := fmt.Sprintf("%s/%s", storagePath, hash)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
outFile, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create skin file: %w", err)
}
defer outFile.Close()
_, err = outFile.Write(fileBytes)
if err != nil {
return fmt.Errorf("failed to write skin file: %w", err)
}
}
return s.UserRepo.UpdateSkinHash(ctx, userID, hash)
}