[BREAKING]

Introduce universal profile entity
Remove fs-based capes serving
Rework management API
Rework Redis storage schema
Reducing amount of the bus emitter usage
This commit is contained in:
ErickSkrauch 2024-01-30 09:05:04 +01:00
parent dac5e4967f
commit dac3ca9001
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
32 changed files with 1979 additions and 2406 deletions

View File

@ -1,2 +0,0 @@
*
!.gitignore

View File

@ -1,34 +0,0 @@
package fs
import (
"os"
"path"
"strings"
"github.com/elyby/chrly/model"
)
func New(basePath string) (*Filesystem, error) {
return &Filesystem{path: basePath}, nil
}
// Deprecated
type Filesystem struct {
path string
}
func (f *Filesystem) FindCapeByUsername(username string) (*model.Cape, error) {
capePath := path.Join(f.path, strings.ToLower(username)+".png")
file, err := os.Open(capePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &model.Cape{
File: file,
}, nil
}

View File

@ -1,56 +0,0 @@
package fs
import (
"fmt"
"io/ioutil"
"os"
"path"
"testing"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
fs, err := New("base/path")
require.Nil(t, err)
require.Equal(t, "base/path", fs.path)
}
func TestFilesystem(t *testing.T) {
t.Run("FindCapeByUsername", func(t *testing.T) {
dir, err := ioutil.TempDir("", "capes")
if err != nil {
panic(fmt.Errorf("cannot crete temp directory for tests: %w", err))
}
defer os.RemoveAll(dir)
t.Run("exists cape", func(t *testing.T) {
file, err := os.Create(path.Join(dir, "username.png"))
if err != nil {
panic(fmt.Errorf("cannot create temp skin for tests: %w", err))
}
defer os.Remove(file.Name())
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("username")
require.Nil(t, err)
require.NotNil(t, cape)
capeFile, _ := cape.File.(*os.File)
require.Equal(t, file.Name(), capeFile.Name())
})
t.Run("not exists cape", func(t *testing.T) {
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("username")
require.Nil(t, err)
require.Nil(t, cape)
})
t.Run("empty username", func(t *testing.T) {
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("")
require.Nil(t, err)
require.Nil(t, cape)
})
})
}

18
db/model.go Normal file
View File

@ -0,0 +1,18 @@
package db
type Profile struct {
// Uuid contains user's UUID without dashes in lower case
Uuid string
// Username contains user's username with the original casing
Username string
// SkinUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a skin
SkinUrl string
// SkinModel contains skin's model. It will be empty when the model is default
SkinModel string
// CapeUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a cape
CapeUrl string
// MojangTextures contains the original textures value from Mojang's skinsystem
MojangTextures string
// MojangSignature contains the original textures signature from Mojang's skinsystem
MojangSignature string
}

View File

@ -1,24 +1,25 @@
package redis
import (
"bytes"
"compress/zlib"
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/mediocregopher/radix/v4"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/db"
)
var now = time.Now
const usernameToProfileKey = "hash:username-to-profile"
const userUuidToUsernameKey = "hash:uuid-to-username"
func New(ctx context.Context, addr string, poolSize int) (*Redis, error) {
type Redis struct {
client radix.Client
context context.Context
serializer db.ProfileSerializer
}
func New(ctx context.Context, profileSerializer db.ProfileSerializer, addr string, poolSize int) (*Redis, error) {
client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr)
if err != nil {
return nil, err
@ -27,33 +28,25 @@ func New(ctx context.Context, addr string, poolSize int) (*Redis, error) {
return &Redis{
client: client,
context: ctx,
serializer: profileSerializer,
}, nil
}
const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username"
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
type Redis struct {
client radix.Client
context context.Context
}
func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) {
var skin *model.Skin
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
func (r *Redis) FindProfileByUsername(username string) (*db.Profile, error) {
var profile *db.Profile
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
skin, err = findByUsername(ctx, conn, username)
profile, err = r.findProfileByUsername(ctx, conn, username)
return err
}))
return skin, err
return profile, err
}
func findByUsername(ctx context.Context, conn radix.Conn, username string) (*model.Skin, error) {
redisKey := buildUsernameKey(username)
func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) {
var encodedResult []byte
err := conn.Do(ctx, radix.Cmd(&encodedResult, "GET", redisKey))
err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username)))
if err != nil {
return nil, err
}
@ -62,33 +55,14 @@ func findByUsername(ctx context.Context, conn radix.Conn, username string) (*mod
return nil, nil
}
result, err := zlibDecode(encodedResult)
if err != nil {
return nil, err
return r.serializer.Deserialize(encodedResult)
}
var skin *model.Skin
err = json.Unmarshal(result, &skin)
if err != nil {
return nil, err
}
// Some old data causing issues in the production.
// TODO: remove after investigation will be finished
if skin.Uuid == "" {
return nil, nil
}
skin.OldUsername = skin.Username
return skin, nil
}
func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
var skin *model.Skin
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
func (r *Redis) FindProfileByUuid(uuid string) (*db.Profile, error) {
var skin *db.Profile
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
skin, err = findByUserId(ctx, conn, id)
skin, err = r.findProfileByUuid(ctx, conn, uuid)
return err
}))
@ -96,9 +70,8 @@ func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
return skin, err
}
func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, error) {
var username string
err := conn.Do(ctx, radix.FlatCmd(&username, "HGET", accountIdToUsernameKey, id))
func (r *Redis) findProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) (*db.Profile, error) {
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
if err != nil {
return nil, err
}
@ -107,36 +80,51 @@ func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, er
return nil, nil
}
return findByUsername(ctx, conn, username)
return r.findProfileByUsername(ctx, conn, username)
}
func (db *Redis) SaveSkin(skin *model.Skin) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return save(ctx, conn, skin)
func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) {
var username string
return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid)))
}
func (r *Redis) SaveProfile(profile *db.Profile) error {
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return r.saveProfile(ctx, conn, profile)
}))
}
func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error {
err := conn.Do(ctx, radix.Cmd(nil, "MULTI"))
func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error {
newUsernameHashKey := usernameHashKey(profile.Username)
existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid)
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
// If user has changed username, then we must delete his old username record
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(skin.OldUsername)))
if existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey))
if err != nil {
return err
}
}
// If this is a new record or if the user has changed username, we set the value in the hash table
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", accountIdToUsernameKey, skin.UserId, skin.Username))
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey))
if err != nil {
return err
}
str, _ := json.Marshal(skin)
err = conn.Do(ctx, radix.FlatCmd(nil, "SET", buildUsernameKey(skin.Username), zlibEncode(str)))
serializedProfile, err := r.serializer.Serialize(profile)
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile))
if err != nil {
return err
}
@ -146,19 +134,17 @@ func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error {
return err
}
skin.OldUsername = skin.Username
return nil
}
func (db *Redis) RemoveSkinByUserId(id int) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return removeByUserId(ctx, conn, id)
func (r *Redis) RemoveProfileByUuid(uuid string) error {
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return r.removeProfileByUuid(ctx, conn, uuid)
}))
}
func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
record, err := findByUserId(ctx, conn, id)
func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error {
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
if err != nil {
return err
}
@ -168,13 +154,13 @@ func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, id))
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", userUuidToUsernameKey, normalizeUuid(uuid)))
if err != nil {
return err
}
if record != nil {
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username)))
if username != "" {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username)))
if err != nil {
return err
}
@ -183,44 +169,10 @@ func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
}
func (db *Redis) RemoveSkinByUsername(username string) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return removeByUsername(ctx, conn, username)
}))
}
func removeByUsername(ctx context.Context, conn radix.Conn, username string) error {
record, err := findByUsername(ctx, conn, username)
if err != nil {
return err
}
if record == nil {
return nil
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username)))
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, record.UserId))
if err != nil {
return err
}
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
}
func (db *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
func (r *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
var uuid string
foundUsername := username
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
@ -231,9 +183,9 @@ func (db *Redis) GetUuidForMojangUsername(username string) (string, string, erro
}
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
key := strings.ToLower(username)
key := buildMojangUsernameKey(username)
var result string
err := conn.Do(ctx, radix.Cmd(&result, "HGET", mojangUsernameToUuidKey, key))
err := conn.Do(ctx, radix.Cmd(&result, "GET", key))
if err != nil {
return "", "", err
}
@ -243,51 +195,19 @@ func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username str
}
parts := strings.Split(result, ":")
partsCnt := len(parts)
// https://github.com/elyby/chrly/issues/28
if partsCnt < 2 {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
if err != nil {
return "", "", err
return parts[1], parts[0], nil
}
return "", "", fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result)
}
var casedUsername, uuid, rawTimestamp string
if partsCnt == 2 { // Legacy, when original username wasn't stored
casedUsername = username
uuid = parts[0]
rawTimestamp = parts[1]
} else {
casedUsername = parts[0]
uuid = parts[1]
rawTimestamp = parts[2]
}
timestamp, _ := strconv.ParseInt(rawTimestamp, 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
if err != nil {
return "", "", err
}
return "", "", nil
}
return uuid, casedUsername, nil
}
func (db *Redis) StoreMojangUuid(username string, uuid string) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
func (r *Redis) StoreMojangUuid(username string, uuid string) error {
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return storeMojangUuid(ctx, conn, username, uuid)
}))
}
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
value := fmt.Sprintf("%s:%s:%d", username, uuid, now().Unix())
err := conn.Do(ctx, radix.Cmd(nil, "HSET", mojangUsernameToUuidKey, strings.ToLower(username), value))
value := fmt.Sprintf("%s:%s", username, uuid)
err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30))
if err != nil {
return err
}
@ -295,33 +215,18 @@ func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid
return nil
}
func (db *Redis) Ping() error {
return db.client.Do(db.context, radix.Cmd(nil, "PING"))
func (r *Redis) Ping() error {
return r.client.Do(r.context, radix.Cmd(nil, "PING"))
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
func normalizeUuid(uuid string) string {
return strings.ToLower(strings.ReplaceAll(uuid, "-", ""))
}
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
_, _ = writer.Write(str)
_ = writer.Close()
return buff.Bytes()
func usernameHashKey(username string) string {
return strings.ToLower(username)
}
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, err := zlib.NewReader(buff)
if err != nil {
return nil, err
}
resultBuffer := new(bytes.Buffer)
_, _ = io.Copy(resultBuffer, reader)
_ = reader.Close()
return resultBuffer.Bytes(), nil
func buildMojangUsernameKey(username string) string {
return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username))
}

View File

@ -4,17 +4,18 @@ package redis
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"testing"
"time"
"github.com/mediocregopher/radix/v4"
"github.com/stretchr/testify/mock"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/db"
)
var redisAddr string
@ -33,15 +34,35 @@ func init() {
redisAddr = fmt.Sprintf("%s:%d", host, port)
}
type MockProfileSerializer struct {
mock.Mock
}
func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) {
args := m.Called(profile)
return []byte(args.String(0)), args.Error(1)
}
func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) {
args := m.Called(value)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(context.Background(), redisAddr, 12)
conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
})
t.Run("should return error", func(t *testing.T) {
conn, err := New(context.Background(), "localhost:12345", 12) // Use localhost to avoid DNS resolution
conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution
assert.Error(t, err)
assert.Nil(t, conn)
})
@ -51,21 +72,24 @@ type redisTestSuite struct {
suite.Suite
Redis *Redis
Serializer *MockProfileSerializer
cmd func(cmd string, args ...interface{}) string
}
func (suite *redisTestSuite) SetupSuite() {
func (s *redisTestSuite) SetupSuite() {
s.Serializer = &MockProfileSerializer{}
ctx := context.Background()
conn, err := New(ctx, redisAddr, 10)
conn, err := New(ctx, s.Serializer, redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
suite.Redis = conn
suite.cmd = func(cmd string, args ...interface{}) string {
s.Redis = conn
s.cmd = func(cmd string, args ...interface{}) string {
var result string
err := suite.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
if err != nil {
panic(err)
}
@ -74,365 +98,202 @@ func (suite *redisTestSuite) SetupSuite() {
}
}
func (suite *redisTestSuite) SetupTest() {
func (s *redisTestSuite) SetupSubTest() {
// Cleanup database before each test
suite.cmd("FLUSHALL")
s.cmd("FLUSHALL")
}
func (suite *redisTestSuite) TearDownTest() {
// Restore time.Now func
now = time.Now
func (s *redisTestSuite) TearDownSubTest() {
s.Serializer.AssertExpectations(s.T())
for _, call := range s.Serializer.ExpectedCalls {
call.Unset()
}
func (suite *redisTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
}
func TestRedis(t *testing.T) {
suite.Run(t, new(redisTestSuite))
}
/**
* JSON with zlib encoding
* {
* userId: 1,
* uuid: "fd5da1e4d66d4d17aadee2446093896d",
* username: "Mock",
* skinId: 1,
* url: "http://localhost/skin.png",
* is1_8: true,
* isSlim: false,
* mojangTextures: "mock-mojang-textures",
* mojangSignature: "mock-mojang-signature"
* }
*/
var skinRecord = string([]byte{
0x78, 0x9c, 0x5c, 0xce, 0x4b, 0x4a, 0x4, 0x41, 0xc, 0xc6, 0xf1, 0xbb, 0x7c, 0xeb, 0x1a, 0xdb, 0xd6, 0xb2,
0x9c, 0xc9, 0xd, 0x5c, 0x88, 0x8b, 0xd1, 0xb5, 0x84, 0x4e, 0xa6, 0xa7, 0xec, 0x7a, 0xc, 0xf5, 0x0, 0x41,
0xbc, 0xbb, 0xb4, 0xd2, 0xa, 0x2e, 0xf3, 0xe3, 0x9f, 0x90, 0xf, 0xf4, 0xaa, 0xe5, 0x41, 0x40, 0xa3, 0x41,
0xef, 0x5e, 0x40, 0x38, 0xc9, 0x9d, 0xf0, 0xa8, 0x56, 0x9c, 0x13, 0x2b, 0xe3, 0x3d, 0xb3, 0xa8, 0xde, 0x58,
0xeb, 0xae, 0xf, 0xb7, 0xfb, 0x83, 0x13, 0x98, 0xef, 0xa5, 0xc4, 0x51, 0x41, 0x78, 0xcc, 0xd3, 0x2, 0x83,
0xba, 0xf8, 0xb4, 0x9d, 0x29, 0x1, 0x84, 0x73, 0x6b, 0x17, 0x1a, 0x86, 0x90, 0x27, 0xe, 0xe7, 0x5c, 0xdb,
0xb0, 0x16, 0x57, 0x97, 0x34, 0xc3, 0xc0, 0xd7, 0xf1, 0x75, 0xf, 0x6a, 0xa5, 0xeb, 0x3a, 0x1c, 0x83, 0x8f,
0xa0, 0x13, 0x87, 0xaa, 0x6, 0x31, 0xbf, 0x71, 0x9a, 0x9f, 0xf5, 0xbd, 0xf5, 0xa2, 0x15, 0x84, 0x98, 0xa7,
0x65, 0xf7, 0xa3, 0xbb, 0xb6, 0xf1, 0xd6, 0x1d, 0xfd, 0x9c, 0x78, 0xa5, 0x7f, 0x61, 0xfd, 0x75, 0x83, 0xa7,
0x20, 0x2f, 0x7f, 0xff, 0xe2, 0xf3, 0x2b, 0x0, 0x0, 0xff, 0xff, 0x6f, 0xdd, 0x51, 0x71,
func (s *redisTestSuite) TestFindProfileByUsername() {
s.Run("exists record", func() {
serializedData := []byte("mock.exists.profile")
expectedProfile := &db.Profile{}
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
profile, err := s.Redis.FindProfileByUsername("Mock")
s.Require().NoError(err)
s.Require().Same(expectedProfile, profile)
})
func (suite *redisTestSuite) TestFindSkinByUsername() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(err)
suite.Require().NotNil(skin)
suite.Require().Equal(1, skin.UserId)
suite.Require().Equal("fd5da1e4d66d4d17aadee2446093896d", skin.Uuid)
suite.Require().Equal("Mock", skin.Username)
suite.Require().Equal(1, skin.SkinId)
suite.Require().Equal("http://localhost/skin.png", skin.Url)
suite.Require().True(skin.Is1_8)
suite.Require().False(skin.IsSlim)
suite.Require().Equal("mock-mojang-textures", skin.MojangTextures)
suite.Require().Equal("mock-mojang-signature", skin.MojangSignature)
suite.Require().Equal(skin.Username, skin.OldUsername)
s.Run("not exists record", func() {
profile, err := s.Redis.FindProfileByUsername("Mock")
s.Require().NoError(err)
s.Require().Nil(profile)
})
suite.RunSubTest("not exists record", func() {
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
s.Run("an error from serializer implementation", func() {
expectedError := errors.New("mock error")
s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data")
s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError)
suite.RunSubTest("invalid zlib encoding", func() {
suite.cmd("SET", "username:mock", "this is really not zlib")
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(skin)
suite.Require().EqualError(err, "zlib: invalid header")
})
suite.RunSubTest("invalid json encoding", func() {
suite.cmd("SET", "username:mock", []byte{
0x78, 0x9c, 0xca, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x28, 0xcf, 0x2f, 0xca, 0x49, 0x1, 0x4, 0x0, 0x0, 0xff,
0xff, 0x1a, 0xb, 0x4, 0x5d,
})
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(skin)
suite.Require().EqualError(err, "invalid character 'h' looking for beginning of value")
profile, err := s.Redis.FindProfileByUsername("Mock")
s.Require().Nil(profile)
s.Require().ErrorIs(err, expectedError)
})
}
func (suite *redisTestSuite) TestFindSkinByUserId() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
func (s *redisTestSuite) TestFindProfileByUuid() {
s.Run("exists record", func() {
serializedData := []byte("mock.exists.profile")
expectedProfile := &db.Profile{Username: "Mock"}
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().NotNil(skin)
suite.Require().Equal(1, skin.UserId)
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
s.Require().Same(expectedProfile, profile)
})
suite.RunSubTest("not exists record", func() {
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().Nil(skin)
s.Run("not exists record", func() {
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
s.Require().Nil(profile)
})
suite.RunSubTest("exists hash record, but no skin record", func() {
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().Nil(skin)
s.Run("exists uuid record, but related profile not exists", func() {
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
s.Require().Nil(profile)
})
}
func (suite *redisTestSuite) TestSaveSkin() {
suite.RunSubTest("save new entity", func() {
err := suite.Redis.SaveSkin(&model.Skin{
UserId: 1,
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
func (s *redisTestSuite) TestSaveProfile() {
s.Run("save new entity", func() {
profile := &db.Profile{
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
Username: "Mock",
SkinId: 1,
Url: "http://localhost/skin.png",
Is1_8: true,
IsSlim: false,
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
})
suite.Require().Nil(err)
}
serializedProfile := "serialized-profile"
s.Serializer.On("Serialize", profile).Return(serializedProfile, nil)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal(skinRecord, usernameResp)
s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile)
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Equal("Mock", idResp)
err := s.Redis.SaveProfile(profile)
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Equal("mock", uuidResp)
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Equal(serializedProfile, profileResp)
})
suite.RunSubTest("save exists record with changed username", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.SaveSkin(&model.Skin{
UserId: 1,
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
s.Run("update exists record with changed username", func() {
newProfile := &db.Profile{
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
Username: "NewMock",
SkinId: 1,
Url: "http://localhost/skin.png",
Is1_8: true,
IsSlim: false,
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
OldUsername: "Mock",
})
suite.Require().Nil(err)
}
serializedNewProfile := "serialized-new-profile"
s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil)
usernameResp := suite.cmd("GET", "username:newmock")
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal(string([]byte{
0x78, 0x9c, 0x5c, 0x8e, 0xcb, 0x4e, 0xc3, 0x40, 0xc, 0x45, 0xff, 0xe5, 0xae, 0xa7, 0x84, 0x40, 0x18, 0x5a,
0xff, 0x1, 0xb, 0x60, 0x51, 0x58, 0x23, 0x2b, 0x76, 0xd3, 0x21, 0xf3, 0xa8, 0xe6, 0x21, 0x90, 0x10, 0xff,
0x8e, 0x52, 0x14, 0x90, 0xba, 0xf4, 0xd1, 0xf1, 0xd5, 0xf9, 0x42, 0x2b, 0x9a, 0x1f, 0x4, 0xd4, 0x1b, 0xb4,
0xe6, 0x4, 0x84, 0x83, 0xdc, 0x9, 0xf7, 0x3a, 0x88, 0xb5, 0x32, 0x48, 0x7f, 0xcf, 0x2c, 0xaa, 0x37, 0xc3,
0x60, 0xaf, 0x77, 0xb7, 0xdb, 0x9d, 0x15, 0x98, 0xf3, 0x53, 0xe4, 0xa0, 0x20, 0x3c, 0xe9, 0xc7, 0x63, 0x1a,
0x67, 0x18, 0x94, 0xd9, 0xc5, 0x75, 0x29, 0x7b, 0x10, 0x8e, 0xb5, 0x9e, 0xa8, 0xeb, 0x7c, 0x1a, 0xd9, 0x1f,
0x53, 0xa9, 0xdd, 0x62, 0x5c, 0x9d, 0xe2, 0x4, 0x3, 0x57, 0xfa, 0xb7, 0x2d, 0xa8, 0xe6, 0xa6, 0xcb, 0xb1,
0xf7, 0x2e, 0x80, 0xe, 0xec, 0x8b, 0x1a, 0x84, 0xf4, 0xce, 0x71, 0x7a, 0xd1, 0xcf, 0xda, 0xb2, 0x16, 0x10,
0x42, 0x1a, 0xe7, 0xcd, 0x2f, 0xdd, 0xd4, 0x15, 0xaf, 0xde, 0xde, 0x4d, 0x91, 0x17, 0x74, 0x21, 0x96, 0x3f,
0x6e, 0xf0, 0xec, 0xe5, 0xf5, 0x3f, 0xf9, 0xdc, 0xfb, 0xfd, 0x13, 0x0, 0x0, 0xff, 0xff, 0xca, 0xc3, 0x54,
0x25,
}), usernameResp)
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile")
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
oldUsernameResp := suite.cmd("GET", "username:mock")
suite.Require().Empty(oldUsernameResp)
err := s.Redis.SaveProfile(newProfile)
s.Require().NoError(err)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal("NewMock", idResp)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Equal("newmock", uuidResp)
newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock")
s.Require().Equal(serializedNewProfile, newProfileResp)
oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Empty(oldProfileResp)
})
}
func (suite *redisTestSuite) TestRemoveSkinByUserId() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
func (s *redisTestSuite) TestRemoveProfileByUuid() {
s.Run("exists record", func() {
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile")
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().Nil(err)
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().Empty(usernameResp)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Empty(uuidResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Empty(idResp)
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Empty(profileResp)
})
suite.RunSubTest("exists only id", func() {
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
s.Run("uuid exists, username is missing", func() {
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().Nil(err)
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Empty(idResp)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Empty(uuidResp)
})
suite.RunSubTest("error when querying skin record", func() {
suite.cmd("SET", "username:mock", "invalid zlib")
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().EqualError(err, "zlib: invalid header")
s.Run("uuid not exists", func() {
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
})
}
func (suite *redisTestSuite) TestRemoveSkinByUsername() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
func (s *redisTestSuite) TestGetUuidForMojangUsername() {
s.Run("exists record", func() {
s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().Empty(usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Empty(idResp)
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
s.Require().NoError(err)
s.Require().Equal("MoCk", username)
s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("exists only username", func() {
suite.cmd("SET", "username:mock", skinRecord)
s.Run("exists record with empty uuid value", func() {
s.cmd("SET", "mojang:uuid:mock", "MoCk:")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().Empty(usernameResp)
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
s.Require().NoError(err)
s.Require().Equal("MoCk", username)
s.Require().Empty(uuid)
})
suite.RunSubTest("no records", func() {
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
})
suite.RunSubTest("error when querying skin record", func() {
suite.cmd("SET", "username:mock", "invalid zlib")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().EqualError(err, "zlib: invalid header")
s.Run("not exists record", func() {
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
s.Require().NoError(err)
s.Require().Empty(username)
s.Require().Empty(uuid)
})
}
func (suite *redisTestSuite) TestGetUuid() {
suite.RunSubTest("exists record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
)
func (s *redisTestSuite) TestStoreUuid() {
s.Run("store uuid", func() {
err := s.Redis.StoreMojangUuid("MoCk", "d3ca513eb3e14946b58047f2bd3530fd")
s.Require().NoError(err)
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
suite.Require().NoError(err)
suite.Require().Equal("MoCk", username)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
resp := s.cmd("GET", "mojang:uuid:mock")
s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
})
suite.RunSubTest("exists record (legacy data)", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
)
s.Run("store empty uuid", func() {
err := s.Redis.StoreMojangUuid("MoCk", "")
s.Require().NoError(err)
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
suite.Require().NoError(err)
suite.Require().Equal("Mock", username)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("exists record with empty uuid value", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s::%d", "MoCk", time.Now().Unix()),
)
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
suite.Require().NoError(err)
suite.Require().Equal("MoCk", username)
suite.Require().Empty(uuid)
})
suite.RunSubTest("not exists record", func() {
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
suite.Require().NoError(err)
suite.Require().Empty(username)
suite.Require().Empty(uuid)
})
suite.RunSubTest("exists, but expired record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
)
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
suite.Require().NoError(err)
suite.Require().Empty(uuid)
suite.Require().Empty(username)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Empty(resp, "should cleanup expired records")
})
suite.RunSubTest("exists, but corrupted record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
"corrupted value",
)
uuid, found, err := suite.Redis.GetUuidForMojangUsername("Mock")
suite.Require().Empty(uuid)
suite.Require().Empty(found)
suite.Require().Error(err, "Got unexpected response from the mojangUsernameToUuid hash: \"corrupted value\"")
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Empty(resp, "should cleanup expired records")
resp := s.cmd("GET", "mojang:uuid:mock")
s.Require().Equal(resp, "MoCk:")
})
}
func (suite *redisTestSuite) TestStoreUuid() {
suite.RunSubTest("store uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreMojangUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Equal(resp, "Mock:d3ca513eb3e14946b58047f2bd3530fd:1587435016")
})
suite.RunSubTest("store empty uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreMojangUuid("Mock", "")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Equal(resp, "Mock::1587435016")
})
}
func (suite *redisTestSuite) TestPing() {
err := suite.Redis.Ping()
suite.Require().Nil(err)
func (s *redisTestSuite) TestPing() {
err := s.Redis.Ping()
s.Require().Nil(err)
}

136
db/serializer.go Normal file
View File

@ -0,0 +1,136 @@
package db
import (
"bytes"
"compress/zlib"
"io"
"strings"
"github.com/valyala/fastjson"
)
type ProfileSerializer interface {
Serialize(profile *Profile) ([]byte, error)
Deserialize(value []byte) (*Profile, error)
}
func NewJsonSerializer() *JsonSerializer {
return &JsonSerializer{
parserPool: &fastjson.ParserPool{},
}
}
type JsonSerializer struct {
parserPool *fastjson.ParserPool
}
// Reasons for manual JSON serialization:
// 1. The Profile must be pure and must not contain tags.
// 2. Without tags it's impossible to apply omitempty during serialization.
// 3. Without omitempty we significantly inflate the storage size, which is critical for large deployments.
// Since the JSON structure in this case is very simple, it's very easy to write a manual serialization,
// achieving all constraints above.
func (s *JsonSerializer) Serialize(profile *Profile) ([]byte, error) {
var builder strings.Builder
// Prepare for the worst case (e.g. long username, long textures links, long Mojang textures and signature)
// to prevent additional memory allocations during serialization
builder.Grow(1536)
builder.WriteString(`{"uuid":"`)
builder.WriteString(profile.Uuid)
builder.WriteString(`","username":"`)
builder.WriteString(profile.Username)
builder.WriteString(`"`)
if profile.SkinUrl != "" {
builder.WriteString(`,"skinUrl":"`)
builder.WriteString(profile.SkinUrl)
builder.WriteString(`"`)
if profile.SkinModel != "" {
builder.WriteString(`,"skinModel":"`)
builder.WriteString(profile.SkinModel)
builder.WriteString(`"`)
}
}
if profile.CapeUrl != "" {
builder.WriteString(`,"capeUrl":"`)
builder.WriteString(profile.CapeUrl)
builder.WriteString(`"`)
}
if profile.MojangTextures != "" {
builder.WriteString(`,"mojangTextures":"`)
builder.WriteString(profile.MojangTextures)
builder.WriteString(`","mojangSignature":"`)
builder.WriteString(profile.MojangSignature)
builder.WriteString(`"`)
}
builder.WriteString("}")
return []byte(builder.String()), nil
}
func (s *JsonSerializer) Deserialize(value []byte) (*Profile, error) {
parser := s.parserPool.Get()
defer s.parserPool.Put(parser)
v, err := parser.ParseBytes(value)
if err != nil {
return nil, err
}
profile := &Profile{
Uuid: string(v.GetStringBytes("uuid")),
Username: string(v.GetStringBytes("username")),
SkinUrl: string(v.GetStringBytes("skinUrl")),
SkinModel: string(v.GetStringBytes("skinModel")),
CapeUrl: string(v.GetStringBytes("capeUrl")),
MojangTextures: string(v.GetStringBytes("mojangTextures")),
MojangSignature: string(v.GetStringBytes("mojangSignature")),
}
return profile, nil
}
func NewZlibEncoder(serializer ProfileSerializer) *ZlibEncoder {
return &ZlibEncoder{serializer}
}
type ZlibEncoder struct {
serializer ProfileSerializer
}
func (s *ZlibEncoder) Serialize(profile *Profile) ([]byte, error) {
serialized, err := s.serializer.Serialize(profile)
if err != nil {
return nil, err
}
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
_, err = writer.Write(serialized)
if err != nil {
return nil, err
}
_ = writer.Close()
return buff.Bytes(), nil
}
func (s *ZlibEncoder) Deserialize(value []byte) (*Profile, error) {
buff := bytes.NewReader(value)
reader, err := zlib.NewReader(buff)
if err != nil {
return nil, err
}
resultBuffer := new(bytes.Buffer)
_, err = io.Copy(resultBuffer, reader)
if err != nil {
return nil, err
}
_ = reader.Close()
return s.serializer.Deserialize(resultBuffer.Bytes())
}

194
db/serializer_test.go Normal file
View File

@ -0,0 +1,194 @@
package db
import (
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestJsonSerializer(t *testing.T) {
var testCases = map[string]*struct {
*Profile
Serialized []byte
Error error
}{
"full structure": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`),
},
"default skin model": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png"}`),
},
"cape only": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
CapeUrl: "https://example.com/cape.png",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","capeUrl":"https://example.com/cape.png"}`),
},
"minimal structure": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username"}`),
},
"invalid json structure": {
Serialized: []byte(`this is not json`),
Error: errors.New(`cannot parse JSON: unexpected value found: "this is not json"; unparsed tail: "this is not json"`),
},
}
serializer := NewJsonSerializer()
t.Run("Serialize", func(t *testing.T) {
for n, c := range testCases {
if c.Profile == nil {
continue
}
t.Run(n, func(t *testing.T) {
result, err := serializer.Serialize(c.Profile)
require.NoError(t, err)
require.Equal(t, c.Serialized, result)
})
}
})
t.Run("Deserialize", func(t *testing.T) {
for n, c := range testCases {
t.Run(n, func(t *testing.T) {
result, err := serializer.Deserialize(c.Serialized)
require.Equal(t, c.Error, err)
require.Equal(t, c.Profile, result)
})
}
})
}
type ProfileSerializerMock struct {
mock.Mock
}
func (m *ProfileSerializerMock) Serialize(profile *Profile) ([]byte, error) {
args := m.Called(profile)
var result []byte
if casted, ok := args.Get(0).([]byte); ok {
result = casted
}
return result, args.Error(1)
}
func (m *ProfileSerializerMock) Deserialize(value []byte) (*Profile, error) {
args := m.Called(value)
var result *Profile
if casted, ok := args.Get(0).(*Profile); ok {
result = casted
}
return result, args.Error(1)
}
func TestZlibEncoder(t *testing.T) {
profile := &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
}
t.Run("Serialize", func(t *testing.T) {
t.Run("successfully", func(t *testing.T) {
serializer := &ProfileSerializerMock{}
serializer.On("Serialize", profile).Return([]byte("serialized-string"), nil)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Serialize(profile)
require.NoError(t, err)
require.Equal(t, []byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}, result)
})
t.Run("handle error from serializer", func(t *testing.T) {
expectedError := errors.New("mock error")
serializer := &ProfileSerializerMock{}
serializer.On("Serialize", profile).Return(nil, expectedError)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Serialize(profile)
require.Same(t, expectedError, err)
require.Nil(t, result)
})
})
t.Run("Deserialize", func(t *testing.T) {
t.Run("successfully", func(t *testing.T) {
serializer := &ProfileSerializerMock{}
serializer.On("Deserialize", []byte("serialized-string")).Return(profile, nil)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
require.NoError(t, err)
require.Equal(t, profile, result)
})
t.Run("handle an error from deserializer", func(t *testing.T) {
expectedError := errors.New("mock error")
serializer := &ProfileSerializerMock{}
serializer.On("Deserialize", []byte("serialized-string")).Return(nil, expectedError)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
require.Same(t, expectedError, err)
require.Nil(t, result)
})
t.Run("handle invalid zlib encoding", func(t *testing.T) {
encoder := NewZlibEncoder(&ProfileSerializerMock{})
result, err := encoder.Deserialize([]byte{0x6d, 0x6f, 0x63, 0x6b})
require.ErrorContains(t, err, "invalid")
require.Nil(t, result)
})
})
}
func BenchmarkFastJsonSerializer(b *testing.B) {
profile := &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
}
serializedProfile := []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`)
serializer := NewJsonSerializer()
b.Run("Serialize", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = serializer.Serialize(profile)
}
})
b.Run("Deserialize", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = serializer.Deserialize(serializedProfile)
}
})
}

View File

@ -3,15 +3,14 @@ package di
import (
"context"
"fmt"
"path"
"github.com/defval/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/db/fs"
db2 "github.com/elyby/chrly/db"
"github.com/elyby/chrly/db/redis"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/internal/profiles"
"github.com/elyby/chrly/mojang"
)
@ -22,12 +21,10 @@ import (
// all constants in this case point to static specific implementations.
var db = di.Options(
di.Provide(newRedis,
di.As(new(http.SkinsRepository)),
di.As(new(profiles.ProfilesRepository)),
di.As(new(profiles.ProfilesFinder)),
di.As(new(mojang.MojangUuidsStorage)),
),
di.Provide(newFSFactory,
di.As(new(http.CapesRepository)),
),
)
func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) {
@ -37,6 +34,7 @@ func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error
conn, err := redis.New(
context.Background(),
db2.NewZlibEncoder(&db2.JsonSerializer{}),
fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")),
config.GetInt("storage.redis.poolSize"),
)
@ -55,13 +53,3 @@ func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error
return conn, nil
}
func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) {
config.SetDefault("storage.filesystem.basePath", "data")
config.SetDefault("storage.filesystem.capesDirName", "capes")
return fs.New(path.Join(
config.GetString("storage.filesystem.basePath"),
config.GetString("storage.filesystem.capesDirName"),
))
}

View File

@ -10,6 +10,7 @@ func New() (*di.Container, error) {
db,
mojangTextures,
handlers,
profilesDi,
server,
signer,
)

View File

@ -88,29 +88,23 @@ func newHandlerFactory(
func newSkinsystemHandler(
config *viper.Viper,
emitter Emitter,
skinsRepository SkinsRepository,
capesRepository CapesRepository,
mojangTexturesProvider MojangTexturesProvider,
profilesProvider ProfilesProvider,
texturesSigner TexturesSigner,
) *mux.Router {
config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
return (&Skinsystem{
Emitter: emitter,
SkinsRepo: skinsRepository,
CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider,
ProfilesProvider: profilesProvider,
TexturesSigner: texturesSigner,
TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
}).Handler()
}
func newApiHandler(skinsRepository SkinsRepository) *mux.Router {
func newApiHandler(profilesManager ProfilesManager) *mux.Router {
return (&Api{
SkinsRepo: skinsRepository,
ProfilesManager: profilesManager,
}).Handler()
}

View File

@ -8,7 +8,7 @@ import (
"github.com/defval/di"
"github.com/spf13/viper"
chrlyHttp "github.com/elyby/chrly/http"
"github.com/elyby/chrly/internal/profiles"
"github.com/elyby/chrly/mojang"
)
@ -44,7 +44,7 @@ func newMojangApi(config *viper.Viper) (*mojang.MojangApi, error) {
func newMojangTexturesProviderFactory(
container *di.Container,
config *viper.Viper,
) (chrlyHttp.MojangTexturesProvider, error) {
) (profiles.MojangProfilesProvider, error) {
config.SetDefault("mojang_textures.enabled", true)
if !config.GetBool("mojang_textures.enabled") {
return &mojang.NilProvider{}, nil

27
di/profiles.go Normal file
View File

@ -0,0 +1,27 @@
package di
import (
"github.com/defval/di"
. "github.com/elyby/chrly/http"
"github.com/elyby/chrly/internal/profiles"
)
var profilesDi = di.Options(
di.Provide(newProfilesManager, di.As(new(ProfilesManager))),
di.Provide(newProfilesProvider, di.As(new(ProfilesProvider))),
)
func newProfilesManager(r profiles.ProfilesRepository) *profiles.Manager {
return profiles.NewManager(r)
}
func newProfilesProvider(
finder profiles.ProfilesFinder,
mojangProfilesProvider profiles.MojangProfilesProvider,
) *profiles.Provider {
return &profiles.Provider{
ProfilesFinder: finder,
MojangProfilesProvider: mojangProfilesProvider,
}
}

10
go.mod
View File

@ -12,13 +12,14 @@ require (
github.com/defval/di v1.12.0
github.com/etherlabsio/healthcheck/v2 v2.0.0
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea
github.com/go-playground/validator/v10 v10.17.0
github.com/gorilla/mux v1.8.1
github.com/jellydator/ttlcache/v3 v3.1.1
github.com/mediocregopher/radix/v4 v4.1.4
github.com/mono83/slf v0.0.0-20170919161409-79153e9636db
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.1
github.com/thedevsaddam/govalidator v1.9.10
github.com/valyala/fastjson v1.6.4
)
// Dev dependencies
@ -27,13 +28,18 @@ require (
github.com/stretchr/testify v1.8.4
)
// Indirect dependencies
require (
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mono83/udpwriter v1.0.2 // indirect
@ -49,7 +55,9 @@ require (
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tilinna/clock v1.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.16.0 // indirect
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect

29
go.sum
View File

@ -19,12 +19,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea h1:t6e33/eet/VyiHHHKs0cBytUISUWQ/hmQwOlqtFoGEo=
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
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.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/goradd/maps v0.1.5 h1:Ut7BPJgNy5BYbleI3LswVJJquiM8X5uN0ZuZBHSdRUI=
github.com/goradd/maps v0.1.5/go.mod h1:E5X1CHMgfVm1qFTHgXpgVLVylO5wtlhZdB93dRGjnc0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
@ -41,6 +49,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts=
@ -65,8 +75,6 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM=
github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@ -87,20 +95,25 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU=
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI=
github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao=
github.com/zyedidia/generic v1.2.1 h1:Zv5KS/N2m0XZZiuLS82qheRG4X1o5gsWreGb0hR7XDc=
github.com/zyedidia/generic v1.2.1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=

View File

@ -2,196 +2,72 @@ package http
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/internal/profiles"
)
var regexUuidAny = regexp.MustCompile("(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
func init() {
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
if !regexUuidAny.MatchString(str) {
if message == "" {
message = fmt.Sprintf("The %s field must contain valid UUID", field)
}
return errors.New(message)
}
return nil
})
type ProfilesManager interface {
PersistProfile(profile *db.Profile) error
RemoveProfileByUuid(uuid string) error
}
type Api struct {
SkinsRepo SkinsRepository
ProfilesManager
}
func (ctx *Api) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost)
router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete)
router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete)
router.HandleFunc("/profiles", ctx.postProfileHandler).Methods(http.MethodPost)
router.HandleFunc("/profiles/{uuid}", ctx.deleteProfileByUuidHandler).Methods(http.MethodDelete)
return router
}
func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
apiBadRequest(resp, validationErrors)
func (ctx *Api) postProfileHandler(resp http.ResponseWriter, req *http.Request) {
err := req.ParseForm()
if err != nil {
apiBadRequest(resp, map[string][]string{
"body": {"The body of the request must be a valid url-encoded string"},
})
return
}
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
username := req.Form.Get("username")
profile := &db.Profile{
Uuid: req.Form.Get("uuid"),
Username: req.Form.Get("username"),
SkinUrl: req.Form.Get("skinUrl"),
SkinModel: req.Form.Get("skinModel"),
CapeUrl: req.Form.Get("capeUrl"),
MojangTextures: req.Form.Get("mojangTextures"),
MojangSignature: req.Form.Get("mojangSignature"),
}
record, err := ctx.findIdentityOrCleanup(identityId, username)
err = ctx.PersistProfile(profile)
if err != nil {
panic(err)
var v *profiles.ValidationError
if errors.As(err, &v) {
apiBadRequest(resp, v.Errors)
return
}
if record == nil {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
record.Uuid = strings.ToLower(req.Form.Get("uuid"))
record.SkinId = skinId
record.Is1_8 = is18
record.IsSlim = isSlim
record.Url = req.Form.Get("url")
record.MojangTextures = req.Form.Get("mojangTextures")
record.MojangSignature = req.Form.Get("mojangSignature")
err = ctx.SkinsRepo.SaveSkin(record)
if err != nil {
panic(err)
apiServerError(resp, "Unable to save profile to db", err)
return
}
resp.WriteHeader(http.StatusCreated)
}
func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := ctx.SkinsRepo.FindSkinByUserId(id)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) {
username := mux.Vars(req)["username"]
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
func (ctx *Api) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) {
uuid := mux.Vars(req)["uuid"]
err := ctx.ProfilesManager.RemoveProfileByUuid(uuid)
if err != nil {
panic(err)
}
if skin == nil {
apiNotFound(resp, "Cannot find record for the requested identifier")
apiServerError(resp, "Unable to delete profile from db", err)
return
}
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil {
panic(err)
}
resp.WriteHeader(http.StatusNoContent)
}
func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) {
record, err := ctx.SkinsRepo.FindSkinByUserId(identityId)
if err != nil {
return nil, err
}
if record != nil {
// The username may have changed in the external database,
// so we need to remove the old association
if record.Username != username {
_ = ctx.SkinsRepo.RemoveSkinByUserId(identityId)
record.Username = username
}
return record, nil
}
// If the requested id was not found, then username was reassigned to another user
// who has not uploaded his data to Chrly yet
record, err = ctx.SkinsRepo.FindSkinByUsername(username)
if err != nil {
return nil, err
}
// If the target username does exist, clear it as it will be reassigned to the new user
if record != nil {
_ = ctx.SkinsRepo.RemoveSkinByUsername(username)
record.UserId = identityId
return record, nil
}
return nil, nil
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
_ = request.ParseForm()
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric"},
"url": {},
"is1_8": {"bool"},
"isSlim": {"bool"},
"mojangTextures": {},
"mojangSignature": {},
}
url := request.Form.Get("url")
if url == "" {
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:0,0")
} else {
validationRules["url"] = append(validationRules["url"], "url")
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:1,")
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = append(validationRules["mojangSignature"], "required")
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
})
validationResults := validator.Validate()
if len(validationResults) != 0 {
return validationResults
}
return nil
}

View File

@ -2,417 +2,171 @@ package http
import (
"bytes"
"encoding/base64"
"errors"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/internal/profiles"
)
/***************
* Setup mocks *
***************/
type ProfilesManagerMock struct {
mock.Mock
}
type apiTestSuite struct {
func (m *ProfilesManagerMock) PersistProfile(profile *db.Profile) error {
return m.Called(profile).Error(0)
}
func (m *ProfilesManagerMock) RemoveProfileByUuid(uuid string) error {
return m.Called(uuid).Error(0)
}
type ApiTestSuite struct {
suite.Suite
App *Api
SkinsRepository *skinsRepositoryMock
ProfilesManager *ProfilesManagerMock
}
/********************
* Setup test suite *
********************/
func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.App = &Api{
SkinsRepo: suite.SkinsRepository,
func (t *ApiTestSuite) SetupSubTest() {
t.ProfilesManager = &ProfilesManagerMock{}
t.App = &Api{
ProfilesManager: t.ProfilesManager,
}
}
func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
func (t *ApiTestSuite) TearDownSubTest() {
t.ProfilesManager.AssertExpectations(t.T())
}
func (suite *apiTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
func (t *ApiTestSuite) TestPostProfile() {
t.Run("successfully post profile", func() {
t.ProfilesManager.On("PersistProfile", &db.Profile{
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
Username: "mock_username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "bW9jawo=",
MojangSignature: "bW9jawo=",
}).Once().Return(nil)
/*************
* Run tests *
*************/
func TestApi(t *testing.T) {
suite.Run(t, new(apiTestSuite))
}
/*************************
* Post skin tests cases *
*************************/
type postSkinTestCase struct {
Name string
Form io.Reader
BeforeTest func(suite *apiTestSuite)
PanicErr string
AfterTest func(suite *apiTestSuite, response *http.Response)
}
var postSkinTestsCases = []*postSkinTestCase{
{
Name: "Upload new identity with textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
req := httptest.NewRequest("POST", "http://chrly/profiles", bytes.NewBufferString(url.Values{
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("http://example.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing only textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.True(model.Is1_8)
suite.True(model.IsSlim)
suite.Equal("http://textures-server.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing textures data to empty",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"0"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {""},
"mojangTextures": {""},
"mojangSignature": {""},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(0, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("", model.Url)
suite.Equal("", model.MojangTextures)
suite.Equal("", model.MojangSignature)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := io.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Update exists identity by changing its identityId",
Form: bytes.NewBufferString(url.Values{
"identityId": {"2"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 2).Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUsername", "mock_username").Times(1).Return(nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(2, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its username",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Times(1).Return(nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("changed_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, errors.New("can't find skin by user id"))
},
PanicErr: "can't find skin by user id",
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures"))
},
PanicErr: "can't save textures",
},
}
func (suite *apiTestSuite) TestPostSkin() {
for _, testCase := range postSkinTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
suite.RunSubTest("Get errors about required fields", func() {
req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{
"mojangTextures": {"someBase64EncodedString"},
"skinUrl": {"https://example.com/skin.png"},
"skinModel": {"slim"},
"capeUrl": {"https://example.com/cape.png"},
"mojangTextures": {"bW9jawo="},
"mojangSignature": {"bW9jawo="},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
t.Equal(http.StatusCreated, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.Empty(body)
})
t.Run("handle malformed body", func() {
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader("invalid;=url?encoded_string"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusBadRequest, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
"The identityId field must be numeric",
"The identityId field must be minimum 1 char"
],
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be numeric value between 0 and 0"
],
"username": [
"The username field is required"
],
"uuid": [
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"mojangSignature": [
"The mojangSignature field is required"
"body": [
"The body of the request must be a valid url-encoded string"
]
}
}`, string(body))
})
}
/**************************************
* Delete skin by user id tests cases *
**************************************/
func (suite *apiTestSuite) TestDeleteByUserId() {
suite.RunSubTest("Delete skin by its identity id", func() {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
t.Run("receive validation errors", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(&profiles.ValidationError{
Errors: map[string][]string{
"mock": {"error1", "error2"},
},
})
suite.RunSubTest("Try to remove not exists identity id", func() {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader(""))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
t.Equal(http.StatusBadRequest, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"errors": {
"mock": [
"error1",
"error2"
]
}
}`, string(body))
})
t.Run("receive other error", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(errors.New("mock error"))
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader(""))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
/***************************************
* Delete skin by username tests cases *
***************************************/
func (t *ApiTestSuite) TestDeleteProfileByUuid() {
t.Run("successfully delete", func() {
t.ProfilesManager.On("RemoveProfileByUuid", "0f657aa8-bfbe-415d-b700-5750090d3af3").Once().Return(nil)
func (suite *apiTestSuite) TestDeleteByUsername() {
suite.RunSubTest("Delete skin by its identity username", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
t.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
t.Equal(http.StatusNoContent, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
t.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity username", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
t.Run("error from manager", func() {
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything).Return(errors.New("mock error"))
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
t.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
t.Equal(http.StatusInternalServerError, resp.StatusCode)
})
}
/*************
* Utilities *
*************/
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
func loadSkinFile() []byte {
result := make([]byte, 92)
_, err := base64.StdEncoding.Decode(result, OnePxPng)
if err != nil {
panic(err)
}
return result
func TestApi(t *testing.T) {
suite.Run(t, new(ApiTestSuite))
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
@ -119,6 +120,15 @@ func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string)
_, _ = resp.Write(result)
}
var internalServerError = []byte("Internal server error")
func apiServerError(resp http.ResponseWriter, msg string, err error) {
resp.WriteHeader(http.StatusInternalServerError)
resp.Header().Set("Content-Type", "application/json")
slog.Error(msg, slog.Any("error", err))
_, _ = resp.Write(internalServerError)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")

View File

@ -6,35 +6,21 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/mojang"
"github.com/elyby/chrly/utils"
)
var timeNow = time.Now
type SkinsRepository interface {
FindSkinByUsername(username string) (*model.Skin, error)
FindSkinByUserId(id int) (*model.Skin, error)
SaveSkin(skin *model.Skin) error
RemoveSkinByUserId(id int) error
RemoveSkinByUsername(username string) error
}
type CapesRepository interface {
FindCapeByUsername(username string) (*model.Cape, error)
}
type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
type ProfilesProvider interface {
FindProfileByUsername(username string, allowProxy bool) (*db.Profile, error)
}
type TexturesSigner interface {
@ -43,29 +29,18 @@ type TexturesSigner interface {
}
type Skinsystem struct {
Emitter
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
TexturesSigner TexturesSigner
ProfilesProvider
TexturesSigner
TexturesExtraParamName string
TexturesExtraParamValue string
}
type profile struct {
Id string
Username string
Textures *mojang.TexturesResponse
CapeFile io.Reader
MojangTextures string
MojangSignature string
}
func (ctx *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet)
// TODO: alias /capes/{username}?
router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet)
router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet)
@ -80,17 +55,18 @@ func (ctx *Skinsystem) Handler() *mux.Router {
}
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
profile, err := ctx.ProfilesProvider.FindProfileByUsername(parseUsername(mux.Vars(request)["username"]), true)
if err != nil {
panic(err)
apiServerError(response, "Unable to retrieve a skin", err)
return
}
if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil {
if profile == nil || profile.SkinUrl == "" {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, profile.Textures.Skin.Url, 301)
http.Redirect(response, request, profile.SkinUrl, http.StatusMovedPermanently)
}
func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
@ -106,22 +82,18 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt
}
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
profile, err := ctx.ProfilesProvider.FindProfileByUsername(parseUsername(mux.Vars(request)["username"]), true)
if err != nil {
panic(err)
apiServerError(response, "Unable to retrieve a cape", err)
return
}
if profile == nil || profile.Textures == nil || (profile.CapeFile == nil && profile.Textures.Cape == nil) {
if profile == nil || profile.CapeUrl == "" {
response.WriteHeader(http.StatusNotFound)
return
}
if profile.CapeFile == nil {
http.Redirect(response, request, profile.Textures.Cape.Url, 301)
} else {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, profile.CapeFile)
}
http.Redirect(response, request, profile.CapeUrl, http.StatusMovedPermanently)
}
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
@ -137,34 +109,51 @@ func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *htt
}
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
profile, err := ctx.ProfilesProvider.FindProfileByUsername(mux.Vars(request)["username"], true)
if err != nil {
panic(err)
apiServerError(response, "Unable to retrieve a profile", err)
return
}
if profile == nil || profile.Textures == nil || (profile.Textures.Skin == nil && profile.Textures.Cape == nil) {
if profile == nil {
response.WriteHeader(http.StatusNotFound)
return
}
if profile.SkinUrl == "" && profile.CapeUrl == "" {
response.WriteHeader(http.StatusNoContent)
return
}
responseData, _ := json.Marshal(profile.Textures)
textures := texturesFromProfile(profile)
responseData, _ := json.Marshal(textures)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "")
profile, err := ctx.ProfilesProvider.FindProfileByUsername(
mux.Vars(request)["username"],
getToBool(request.URL.Query().Get("proxy")),
)
if err != nil {
panic(err)
apiServerError(response, "Unable to retrieve a profile", err)
return
}
if profile == nil || profile.MojangTextures == "" {
if profile == nil {
response.WriteHeader(http.StatusNotFound)
return
}
if profile.MojangTextures == "" {
response.WriteHeader(http.StatusNoContent)
return
}
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
profileResponse := &mojang.ProfileResponse{
Id: profile.Uuid,
Name: profile.Username,
Props: []*mojang.Property{
{
@ -185,21 +174,22 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque
}
func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
profile, err := ctx.ProfilesProvider.FindProfileByUsername(mux.Vars(request)["username"], true)
if err != nil {
panic(err)
apiServerError(response, "Unable to retrieve a profile", err)
return
}
if profile == nil {
response.WriteHeader(http.StatusNoContent)
response.WriteHeader(http.StatusNotFound)
return
}
texturesPropContent := &mojang.TexturesProp{
Timestamp: utils.UnixMillisecond(timeNow()),
ProfileID: profile.Id,
ProfileID: profile.Uuid,
ProfileName: profile.Username,
Textures: profile.Textures,
Textures: texturesFromProfile(profile),
}
texturesPropValueJson, _ := json.Marshal(texturesPropContent)
@ -210,17 +200,18 @@ func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *htt
Value: texturesPropEncodedValue,
}
if request.URL.Query().Get("unsigned") == "false" {
if request.URL.Query().Has("unsigned") && !getToBool(request.URL.Query().Get("unsigned")) {
signature, err := ctx.TexturesSigner.SignTextures(texturesProp.Value)
if err != nil {
panic(err)
apiServerError(response, "Unable to sign textures", err)
return
}
texturesProp.Signature = signature
}
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
profileResponse := &mojang.ProfileResponse{
Id: profile.Uuid,
Name: profile.Username,
Props: []*mojang.Property{
texturesProp,
@ -264,101 +255,36 @@ func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWri
}
}
// TODO: in v5 should be extracted into some ProfileProvider interface,
//
// which will encapsulate all logics, declared in this method
func (ctx *Skinsystem) getProfile(request *http.Request, proxy bool) (*profile, error) {
username := parseUsername(mux.Vars(request)["username"])
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err != nil {
return nil, err
}
profile := &profile{
Textures: &mojang.TexturesResponse{}, // Field must be initialized to avoid "null" after json encoding
}
if skin != nil {
profile.Id = strings.Replace(skin.Uuid, "-", "", -1)
profile.Username = skin.Username
}
if skin != nil && skin.Url != "" {
profile.Textures.Skin = &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
profile.Textures.Skin.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
cape, _ := ctx.CapesRepo.FindCapeByUsername(username)
if cape != nil {
profile.CapeFile = cape.File
profile.Textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
profile.MojangTextures = skin.MojangTextures
profile.MojangSignature = skin.MojangSignature
} else if proxy {
mojangProfile, err := ctx.MojangTexturesProvider.GetForUsername(username)
// If we at least know something about the user,
// then we can ignore an error and return profile without textures
if err != nil && profile.Id != "" {
return profile, nil
}
if err != nil || mojangProfile == nil {
if errors.Is(err, mojang.InvalidUsername) {
return nil, nil
}
return nil, err
}
decodedTextures, err := mojangProfile.DecodeTextures()
if err != nil {
return nil, err
}
// There might be no textures property
if decodedTextures != nil {
profile.Textures = decodedTextures.Textures
}
var texturesProp *mojang.Property
for _, prop := range mojangProfile.Props {
if prop.Name == "textures" {
texturesProp = prop
break
}
}
if texturesProp != nil {
profile.MojangTextures = texturesProp.Value
profile.MojangSignature = texturesProp.Signature
}
// If user id is unknown at this point, then use values from Mojang profile
if profile.Id == "" {
profile.Id = mojangProfile.Id
profile.Username = mojangProfile.Name
}
} else if profile.Id != "" {
return profile, nil
} else {
return nil, nil
}
return profile, nil
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}
func getToBool(v string) bool {
return v == "true" || v == "1" || v == "yes"
}
func texturesFromProfile(profile *db.Profile) *mojang.TexturesResponse {
var skin *mojang.SkinTexturesResponse
if profile.SkinUrl != "" {
skin = &mojang.SkinTexturesResponse{
Url: profile.SkinUrl,
}
if profile.SkinModel != "" {
skin.Metadata = &mojang.SkinTexturesMetadata{
Model: profile.SkinModel,
}
}
}
var cape *mojang.CapeTexturesResponse
if profile.CapeUrl != "" {
cape = &mojang.CapeTexturesResponse{
Url: profile.CapeUrl,
}
}
return &mojang.TexturesResponse{
Skin: skin,
Cape: cape,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,122 @@
package profiles
import (
"fmt"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
"github.com/elyby/chrly/db"
)
type ProfilesRepository interface {
FindProfileByUuid(uuid string) (*db.Profile, error)
SaveProfile(profile *db.Profile) error
RemoveProfileByUuid(uuid string) error
}
func NewManager(pr ProfilesRepository) *Manager {
return &Manager{
ProfilesRepository: pr,
profileValidator: createProfileValidator(),
}
}
type Manager struct {
ProfilesRepository
profileValidator *validator.Validate
}
func (m *Manager) PersistProfile(profile *db.Profile) error {
validationErrors := m.profileValidator.Struct(profile)
if validationErrors != nil {
return mapValidationErrorsToCommonError(validationErrors.(validator.ValidationErrors))
}
profile.Uuid = cleanupUuid(profile.Uuid)
if profile.SkinUrl == "" || isClassicModel(profile.SkinModel) {
profile.SkinModel = ""
}
return m.ProfilesRepository.SaveProfile(profile)
}
func (m *Manager) RemoveProfileByUuid(uuid string) error {
return m.ProfilesRepository.RemoveProfileByUuid(cleanupUuid(uuid))
}
type ValidationError struct {
Errors map[string][]string
}
func (e *ValidationError) Error() string {
return "The profile is invalid and cannot be persisted"
}
func cleanupUuid(uuid string) string {
return strings.ReplaceAll(strings.ToLower(uuid), "-", "")
}
func createProfileValidator() *validator.Validate {
validate := validator.New()
regexUuidAny := regexp.MustCompile("(?i)^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$")
_ = validate.RegisterValidation("uuid_any", func(fl validator.FieldLevel) bool {
return regexUuidAny.MatchString(fl.Field().String())
})
regexUsername := regexp.MustCompile(`^[-\w.!$%^&*()\[\]:;]+$`)
_ = validate.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return regexUsername.MatchString(fl.Field().String())
})
validate.RegisterStructValidationMapRules(map[string]string{
"Username": "required,username,max=21",
"Uuid": "required,uuid_any",
"SkinUrl": "omitempty,url",
"SkinModel": "omitempty,max=20",
"CapeUrl": "omitempty,url",
"MojangTextures": "omitempty,base64",
"MojangSignature": "required_with=MojangTextures,omitempty,base64",
}, db.Profile{})
return validate
}
func mapValidationErrorsToCommonError(err validator.ValidationErrors) *ValidationError {
resultErr := &ValidationError{make(map[string][]string)}
for _, e := range err {
// Manager can return multiple errors per field, but the current validation implementation
// returns only one error per field
resultErr.Errors[e.Field()] = []string{formatValidationErr(e)}
}
return resultErr
}
// The go-playground/validator lib already contains tools for translated errors output.
// However, the implementation is very heavy and becomes even more so when you need to add messages for custom validators.
// So for simplicity, I've extracted validation error formatting into this simple implementation
func formatValidationErr(err validator.FieldError) string {
switch err.Tag() {
case "required", "required_with":
return fmt.Sprintf("%s is a required field", err.Field())
case "username":
return fmt.Sprintf("%s must be a valid username", err.Field())
case "max":
return fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param())
case "uuid_any":
return fmt.Sprintf("%s must be a valid UUID", err.Field())
case "url":
return fmt.Sprintf("%s must be a valid URL", err.Field())
case "base64":
return fmt.Sprintf("%s must be a valid Base64 string", err.Field())
default:
return fmt.Sprintf(`Field validation for "%s" failed on the "%s" tag`, err.Field(), err.Tag())
}
}
func isClassicModel(model string) bool {
return model == "" || model == "classic" || model == "default" || model == "steve"
}

View File

@ -0,0 +1,138 @@
package profiles
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/db"
)
type ProfilesRepositoryMock struct {
mock.Mock
}
func (m *ProfilesRepositoryMock) FindProfileByUuid(uuid string) (*db.Profile, error) {
args := m.Called(uuid)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
func (m *ProfilesRepositoryMock) SaveProfile(profile *db.Profile) error {
return m.Called(profile).Error(0)
}
func (m *ProfilesRepositoryMock) RemoveProfileByUuid(uuid string) error {
return m.Called(uuid).Error(0)
}
type ManagerTestSuite struct {
suite.Suite
Manager *Manager
ProfilesRepository *ProfilesRepositoryMock
}
func (t *ManagerTestSuite) SetupSubTest() {
t.ProfilesRepository = &ProfilesRepositoryMock{}
t.Manager = NewManager(t.ProfilesRepository)
}
func (t *ManagerTestSuite) TearDownSubTest() {
t.ProfilesRepository.AssertExpectations(t.T())
}
func (t *ManagerTestSuite) TestPersistProfile() {
t.Run("valid profile (full)", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
}
t.ProfilesRepository.On("SaveProfile", profile).Once().Return(nil)
err := t.Manager.PersistProfile(profile)
t.NoError(err)
})
t.Run("valid profile (minimal)", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "mock-username",
}
t.ProfilesRepository.On("SaveProfile", profile).Once().Return(nil)
err := t.Manager.PersistProfile(profile)
t.NoError(err)
})
t.Run("normalize uuid and skin model", func() {
profile := &db.Profile{
Uuid: "BA866A9C-C839-4268-A30F-7B26AE604C51",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "default",
}
expectedProfile := *profile
expectedProfile.Uuid = "ba866a9cc8394268a30f7b26ae604c51"
expectedProfile.SkinModel = ""
t.ProfilesRepository.On("SaveProfile", &expectedProfile).Once().Return(nil)
err := t.Manager.PersistProfile(profile)
t.NoError(err)
})
t.Run("require mojangSignature when mojangTexturesProvided", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "mock-username",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
}
err := t.Manager.PersistProfile(profile)
t.Error(err)
t.IsType(&ValidationError{}, err)
castedErr := err.(*ValidationError)
mojangSignatureErr, mojangSignatureErrExists := castedErr.Errors["MojangSignature"]
t.True(mojangSignatureErrExists)
t.Contains(mojangSignatureErr[0], "required")
})
t.Run("validate username", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "invalid\"username",
}
err := t.Manager.PersistProfile(profile)
t.Error(err)
t.IsType(&ValidationError{}, err)
castedErr := err.(*ValidationError)
usernameErrs, usernameErrExists := castedErr.Errors["Username"]
t.True(usernameErrExists)
t.Contains(usernameErrs[0], "valid")
})
t.Run("empty profile", func() {
profile := &db.Profile{}
err := t.Manager.PersistProfile(profile)
t.Error(err)
t.IsType(&ValidationError{}, err)
// TODO: validate errors
})
}
func TestManager(t *testing.T) {
suite.Run(t, new(ManagerTestSuite))
}

View File

@ -0,0 +1,88 @@
package profiles
import (
"errors"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/mojang"
)
type ProfilesFinder interface {
FindProfileByUsername(username string) (*db.Profile, error)
}
type MojangProfilesProvider interface {
GetForUsername(username string) (*mojang.ProfileResponse, error)
}
type Provider struct {
ProfilesFinder
MojangProfilesProvider
}
func (p *Provider) FindProfileByUsername(username string, allowProxy bool) (*db.Profile, error) {
profile, err := p.ProfilesFinder.FindProfileByUsername(username)
if err != nil {
return nil, err
}
if profile != nil && (profile.SkinUrl != "" || profile.CapeUrl != "") {
return profile, nil
}
if allowProxy {
mojangProfile, err := p.MojangProfilesProvider.GetForUsername(username)
// If we at least know something about the user,
// then we can ignore an error and return profile without textures
if err != nil && profile != nil {
return profile, nil
}
if err != nil || mojangProfile == nil {
if errors.Is(err, mojang.InvalidUsername) {
return nil, nil
}
return nil, err
}
decodedTextures, err := mojangProfile.DecodeTextures()
if err != nil {
return nil, err
}
profile = &db.Profile{
Uuid: mojangProfile.Id,
Username: mojangProfile.Name,
}
// There might be no textures property
if decodedTextures != nil {
if decodedTextures.Textures.Skin != nil {
profile.SkinUrl = decodedTextures.Textures.Skin.Url
if decodedTextures.Textures.Skin.Metadata != nil {
profile.SkinModel = decodedTextures.Textures.Skin.Metadata.Model
}
}
if decodedTextures.Textures.Cape != nil {
profile.CapeUrl = decodedTextures.Textures.Cape.Url
}
}
var texturesProp *mojang.Property
for _, prop := range mojangProfile.Props {
if prop.Name == "textures" {
texturesProp = prop
break
}
}
if texturesProp != nil {
profile.MojangTextures = texturesProp.Value
profile.MojangSignature = texturesProp.Signature
}
}
return profile, nil
}

View File

@ -0,0 +1,272 @@
package profiles
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/mojang"
"github.com/elyby/chrly/utils"
)
type ProfilesFinderMock struct {
mock.Mock
}
func (m *ProfilesFinderMock) FindProfileByUsername(username string) (*db.Profile, error) {
args := m.Called(username)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
type MojangProfilesProviderMock struct {
mock.Mock
}
func (m *MojangProfilesProviderMock) GetForUsername(username string) (*mojang.ProfileResponse, error) {
args := m.Called(username)
var result *mojang.ProfileResponse
if casted, ok := args.Get(0).(*mojang.ProfileResponse); ok {
result = casted
}
return result, args.Error(1)
}
type CombinedProfilesProviderSuite struct {
suite.Suite
Provider *Provider
ProfilesRepository *ProfilesFinderMock
MojangProfilesProvider *MojangProfilesProviderMock
}
func (t *CombinedProfilesProviderSuite) SetupSubTest() {
t.ProfilesRepository = &ProfilesFinderMock{}
t.MojangProfilesProvider = &MojangProfilesProviderMock{}
t.Provider = &Provider{
ProfilesFinder: t.ProfilesRepository,
MojangProfilesProvider: t.MojangProfilesProvider,
}
}
func (t *CombinedProfilesProviderSuite) TearDownSubTest() {
t.ProfilesRepository.AssertExpectations(t.T())
t.MojangProfilesProvider.AssertExpectations(t.T())
}
func (t *CombinedProfilesProviderSuite) TestFindByUsername() {
t.Run("exists profile with a skin", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
SkinUrl: "https://example.com/skin.png",
}
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("exists profile with a cape", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
CapeUrl: "https://example.com/cape.png",
}
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("exists profile without textures (no proxy)", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
}
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", false)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("not exists profile (no proxy)", func() {
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", false)
t.NoError(err)
t.Nil(foundProfile)
})
t.Run("handle error from profiles repository", func() {
expectedError := errors.New("mock error")
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, expectedError)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", false)
t.Same(expectedError, err)
t.Nil(foundProfile)
})
t.Run("exists profile without textures (with proxy)", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
}
mojangProfile := createMojangProfile(true, true)
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Equal(&db.Profile{
Uuid: "mock-mojang-uuid",
Username: "mOcK",
SkinUrl: "https://mojang/skin.png",
SkinModel: "slim",
CapeUrl: "https://mojang/cape.png",
MojangTextures: mojangProfile.Props[0].Value,
MojangSignature: mojangProfile.Props[0].Signature,
}, foundProfile)
})
t.Run("not exists profile (with proxy)", func() {
mojangProfile := createMojangProfile(true, true)
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Equal(&db.Profile{
Uuid: "mock-mojang-uuid",
Username: "mOcK",
SkinUrl: "https://mojang/skin.png",
SkinModel: "slim",
CapeUrl: "https://mojang/cape.png",
MojangTextures: mojangProfile.Props[0].Value,
MojangSignature: mojangProfile.Props[0].Signature,
}, foundProfile)
})
t.Run("should return known profile without textures when received an error from the mojang", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
}
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, errors.New("mock error"))
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("should not return an error when passed the invalid username", func() {
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, mojang.InvalidUsername)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Nil(foundProfile)
})
t.Run("should return an error from mojang provider", func() {
expectedError := errors.New("mock error")
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, expectedError)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.Same(expectedError, err)
t.Nil(foundProfile)
})
t.Run("should correctly handle invalid textures from mojang", func() {
mojangProfile := &mojang.ProfileResponse{
Props: []*mojang.Property{
{
Name: "textures",
Value: "this is invalid base64",
Signature: "mojang signature",
},
},
}
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.ErrorContains(err, "illegal base64 data")
t.Nil(foundProfile)
})
t.Run("should correctly handle missing textures property from Mojang", func() {
mojangProfile := &mojang.ProfileResponse{
Id: "mock-mojang-uuid",
Name: "mOcK",
Props: []*mojang.Property{},
}
t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername("Mock", true)
t.NoError(err)
t.Equal(&db.Profile{
Uuid: "mock-mojang-uuid",
Username: "mOcK",
}, foundProfile)
})
}
func TestProvider(t *testing.T) {
suite.Run(t, new(CombinedProfilesProviderSuite))
}
func createMojangProfile(withSkin bool, withCape bool) *mojang.ProfileResponse {
timeZone, _ := time.LoadLocation("Europe/Warsaw")
textures := &mojang.TexturesProp{
Timestamp: utils.UnixMillisecond(time.Date(2024, 1, 29, 13, 34, 12, 0, timeZone)),
ProfileID: "mock-mojang-uuid",
ProfileName: "mOcK",
Textures: &mojang.TexturesResponse{},
}
if withSkin {
textures.Textures.Skin = &mojang.SkinTexturesResponse{
Url: "https://mojang/skin.png",
Metadata: &mojang.SkinTexturesMetadata{
Model: "slim",
},
}
}
if withCape {
textures.Textures.Cape = &mojang.CapeTexturesResponse{
Url: "https://mojang/cape.png",
}
}
response := &mojang.ProfileResponse{
Id: textures.ProfileID,
Name: textures.ProfileName,
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
Signature: "mojang signature",
},
},
}
return response
}

View File

@ -1,9 +0,0 @@
package model
import (
"io"
)
type Cape struct {
File io.Reader
}

View File

@ -1,14 +0,0 @@
package model
type Skin struct {
UserId int `json:"userId"`
Uuid string `json:"uuid"`
Username string `json:"username"`
SkinId int `json:"skinId"` // deprecated
Url string `json:"url"`
Is1_8 bool `json:"is1_8"`
IsSlim bool `json:"isSlim"`
MojangTextures string `json:"mojangTextures"`
MojangSignature string `json:"mojangSignature"`
OldUsername string
}

View File

@ -75,7 +75,7 @@ func (c *MojangApi) UsernamesToUuids(usernames []string) ([]*ProfileInfo, error)
// Obtains textures information for provided uuid
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*ProfileResponse, error) {
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := c.profileUrl + normalizedUuid
if signed {
@ -101,7 +101,7 @@ func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesRes
return nil, errorFromResponse(response)
}
var result *SignedTexturesResponse
var result *ProfileResponse
body, _ := io.ReadAll(response.Body)
err = json.Unmarshal(body, &result)
@ -112,7 +112,7 @@ func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesRes
return result, nil
}
type SignedTexturesResponse struct {
type ProfileResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
@ -147,7 +147,7 @@ type CapeTexturesResponse struct {
Url string `json:"url"`
}
func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) {
func (t *ProfileResponse) DecodeTextures() (*TexturesProp, error) {
t.once.Do(func() {
var texturesProp string
for _, prop := range t.Props {

View File

@ -184,7 +184,7 @@ func TestMojangApi(t *testing.T) {
func TestSignedTexturesResponse(t *testing.T) {
t.Run("DecodeTextures", func(t *testing.T) {
obj := &SignedTexturesResponse{
obj := &ProfileResponse{
Id: "00000000000000000000000000000000",
Name: "mock",
Props: []*Property{
@ -200,7 +200,7 @@ func TestSignedTexturesResponse(t *testing.T) {
})
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
obj := &SignedTexturesResponse{
obj := &ProfileResponse{
Id: "00000000000000000000000000000000",
Name: "mock",
Props: []*Property{},

View File

@ -18,24 +18,24 @@ type UuidsProvider interface {
}
type TexturesProvider interface {
GetTextures(uuid string) (*SignedTexturesResponse, error)
GetTextures(uuid string) (*ProfileResponse, error)
}
type MojangTexturesProvider struct {
UuidsProvider
TexturesProvider
group singleflight.Group[string, *SignedTexturesResponse]
group singleflight.Group[string, *ProfileResponse]
}
func (p *MojangTexturesProvider) GetForUsername(username string) (*SignedTexturesResponse, error) {
func (p *MojangTexturesProvider) GetForUsername(username string) (*ProfileResponse, error) {
if !allowedUsernamesRegex.MatchString(username) {
return nil, InvalidUsername
}
username = strings.ToLower(username)
result, err, _ := p.group.Do(username, func() (*SignedTexturesResponse, error) {
result, err, _ := p.group.Do(username, func() (*ProfileResponse, error) {
profile, err := p.UuidsProvider.GetUuid(username)
if err != nil {
return nil, err
@ -54,6 +54,6 @@ func (p *MojangTexturesProvider) GetForUsername(username string) (*SignedTexture
type NilProvider struct {
}
func (*NilProvider) GetForUsername(username string) (*SignedTexturesResponse, error) {
func (*NilProvider) GetForUsername(username string) (*ProfileResponse, error) {
return nil, nil
}

View File

@ -29,10 +29,10 @@ type TexturesProviderMock struct {
mock.Mock
}
func (m *TexturesProviderMock) GetTextures(uuid string) (*SignedTexturesResponse, error) {
func (m *TexturesProviderMock) GetTextures(uuid string) (*ProfileResponse, error) {
args := m.Called(uuid)
var result *SignedTexturesResponse
if casted, ok := args.Get(0).(*SignedTexturesResponse); ok {
var result *ProfileResponse
if casted, ok := args.Get(0).(*ProfileResponse); ok {
result = casted
}
@ -63,7 +63,7 @@ func (suite *providerTestSuite) TearDownTest() {
func (suite *providerTestSuite) TestGetForValidUsernameSuccessfully() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
@ -76,7 +76,6 @@ func (suite *providerTestSuite) TestGetForValidUsernameSuccessfully() {
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
// TODO: check that textures provider wasn't called
result, err := suite.Provider.GetForUsername("username")
@ -98,7 +97,7 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
func (suite *providerTestSuite) TestGetForTheSameUsername() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
awaitChan := make(chan time.Time)
@ -106,7 +105,7 @@ func (suite *providerTestSuite) TestGetForTheSameUsername() {
suite.UuidsProvider.On("GetUuid", "username").Once().WaitUntil(awaitChan).Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
results := make([]*SignedTexturesResponse, 2)
results := make([]*ProfileResponse, 2)
var wgStarted sync.WaitGroup
var wgDone sync.WaitGroup
for i := 0; i < 2; i++ {

View File

@ -8,10 +8,10 @@ import (
)
type MojangApiTexturesProvider struct {
MojangApiTexturesEndpoint func(uuid string, signed bool) (*SignedTexturesResponse, error)
MojangApiTexturesEndpoint func(uuid string, signed bool) (*ProfileResponse, error)
}
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*SignedTexturesResponse, error) {
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*ProfileResponse, error) {
return ctx.MojangApiTexturesEndpoint(uuid, true)
}
@ -20,14 +20,14 @@ func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*SignedTexturesR
type TexturesProviderWithInMemoryCache struct {
provider TexturesProvider
once sync.Once
cache *ttlcache.Cache[string, *SignedTexturesResponse]
cache *ttlcache.Cache[string, *ProfileResponse]
}
func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesProviderWithInMemoryCache {
storage := &TexturesProviderWithInMemoryCache{
provider: provider,
cache: ttlcache.New[string, *SignedTexturesResponse](
ttlcache.WithDisableTouchOnHit[string, *SignedTexturesResponse](),
cache: ttlcache.New[string, *ProfileResponse](
ttlcache.WithDisableTouchOnHit[string, *ProfileResponse](),
// I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error
),
}
@ -35,7 +35,7 @@ func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesPr
return storage
}
func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*SignedTexturesResponse, error) {
func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*ProfileResponse, error) {
item := s.cache.Get(uuid)
// Don't check item.IsExpired() since Get function is already did this check
if item != nil {

View File

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/suite"
)
var signedTexturesResponse = &SignedTexturesResponse{
var signedTexturesResponse = &ProfileResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*Property{
@ -33,10 +33,10 @@ type MojangUuidToTexturesRequestMock struct {
mock.Mock
}
func (m *MojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
func (m *MojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*ProfileResponse, error) {
args := m.Called(uuid, signed)
var result *SignedTexturesResponse
if casted, ok := args.Get(0).(*SignedTexturesResponse); ok {
var result *ProfileResponse
if casted, ok := args.Get(0).(*ProfileResponse); ok {
result = casted
}