174 lines
5.1 KiB
Go
174 lines
5.1 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" // Для валидации 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 {
|
||
// Попробуем PKCS#8, если PKCS#1 не удался
|
||
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
|
||
}
|
||
|
||
// 1. Формируем структуру со свойствами текстур
|
||
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,
|
||
}
|
||
|
||
// 2. Маршализация в JSON и кодирование в Base64
|
||
valueJSON, err := json.Marshal(propValue)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
valueBase64 := base64.StdEncoding.EncodeToString(valueJSON)
|
||
|
||
// 3. Подпись
|
||
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)
|
||
|
||
// 4. Формирование итогового ответа
|
||
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 {
|
||
// 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)
|
||
}
|