mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	[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:
		
							
								
								
									
										2
									
								
								data/capes/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								data/capes/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,2 +0,0 @@ | ||||
| * | ||||
| !.gitignore | ||||
							
								
								
									
										34
									
								
								db/fs/fs.go
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								db/fs/fs.go
									
									
									
									
									
								
							| @@ -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 | ||||
| } | ||||
| @@ -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
									
								
							
							
						
						
									
										18
									
								
								db/model.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @@ -1,59 +1,52 @@ | ||||
| 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 | ||||
| 	} | ||||
|  | ||||
| 	return &Redis{ | ||||
| 		client:  client, | ||||
| 		context: ctx, | ||||
| 		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 | ||||
| 	} | ||||
|  | ||||
| 	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 | ||||
| 	return r.serializer.Deserialize(encodedResult) | ||||
| } | ||||
|  | ||||
| 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 "", "", 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 | ||||
| 	return parts[1], parts[0], 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)) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| 	}) | ||||
| @@ -50,22 +71,25 @@ func TestNew(t *testing.T) { | ||||
| type redisTestSuite struct { | ||||
| 	suite.Suite | ||||
|  | ||||
| 	Redis *Redis | ||||
| 	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 (suite *redisTestSuite) RunSubTest(name string, subTest func()) { | ||||
| 	suite.SetupTest() | ||||
| 	suite.Run(name, subTest) | ||||
| func (s *redisTestSuite) TearDownSubTest() { | ||||
| 	s.Serializer.AssertExpectations(s.T()) | ||||
| 	for _, call := range s.Serializer.ExpectedCalls { | ||||
| 		call.Unset() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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) | ||||
|  | ||||
| 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) | ||||
| 		profile, err := s.Redis.FindProfileByUsername("Mock") | ||||
| 		s.Require().NoError(err) | ||||
| 		s.Require().Same(expectedProfile, profile) | ||||
| 	}) | ||||
|  | ||||
| 	suite.RunSubTest("not exists record", func() { | ||||
| 		skin, err := suite.Redis.FindSkinByUsername("Mock") | ||||
| 		suite.Require().Nil(err) | ||||
| 		suite.Require().Nil(skin) | ||||
| 	s.Run("not exists record", func() { | ||||
| 		profile, err := s.Redis.FindProfileByUsername("Mock") | ||||
| 		s.Require().NoError(err) | ||||
| 		s.Require().Nil(profile) | ||||
| 	}) | ||||
|  | ||||
| 	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") | ||||
| 	}) | ||||
| 	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 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", | ||||
| 			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) | ||||
|  | ||||
| 		usernameResp := suite.cmd("GET", "username:mock") | ||||
| 		suite.Require().NotEmpty(usernameResp) | ||||
| 		suite.Require().Equal(skinRecord, usernameResp) | ||||
|  | ||||
| 		idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) | ||||
| 		suite.Require().Equal("Mock", idResp) | ||||
| 	}) | ||||
|  | ||||
| 	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", | ||||
| 			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) | ||||
|  | ||||
| 		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) | ||||
|  | ||||
| 		oldUsernameResp := suite.cmd("GET", "username:mock") | ||||
| 		suite.Require().Empty(oldUsernameResp) | ||||
|  | ||||
| 		idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) | ||||
| 		suite.Require().NotEmpty(usernameResp) | ||||
| 		suite.Require().Equal("NewMock", idResp) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| 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") | ||||
|  | ||||
| 		err := suite.Redis.RemoveSkinByUserId(1) | ||||
| 		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) | ||||
| 	}) | ||||
|  | ||||
| 	suite.RunSubTest("exists only id", func() { | ||||
| 		suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") | ||||
|  | ||||
| 		err := suite.Redis.RemoveSkinByUserId(1) | ||||
| 		suite.Require().Nil(err) | ||||
|  | ||||
| 		idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) | ||||
| 		suite.Require().Empty(idResp) | ||||
| 	}) | ||||
|  | ||||
| 	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") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| 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") | ||||
|  | ||||
| 		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) | ||||
| 	}) | ||||
|  | ||||
| 	suite.RunSubTest("exists only username", func() { | ||||
| 		suite.cmd("SET", "username:mock", skinRecord) | ||||
|  | ||||
| 		err := suite.Redis.RemoveSkinByUsername("Mock") | ||||
| 		suite.Require().Nil(err) | ||||
|  | ||||
| 		usernameResp := suite.cmd("GET", "username:mock") | ||||
| 		suite.Require().Empty(usernameResp) | ||||
| 	}) | ||||
|  | ||||
| 	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") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| 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()), | ||||
| 		) | ||||
|  | ||||
| 		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 (legacy data)", func() { | ||||
| 		suite.cmd("HSET", | ||||
| 			"hash:mojang-username-to-uuid", | ||||
| 			"mock", | ||||
| 			fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()), | ||||
| 		) | ||||
|  | ||||
| 		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") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| 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) | ||||
| func (s *redisTestSuite) TestSaveProfile() { | ||||
| 	s.Run("save new entity", func() { | ||||
| 		profile := &db.Profile{ | ||||
| 			Uuid:     "f57f36d5-4f50-4728-948a-42d5d80b18f3", | ||||
| 			Username: "Mock", | ||||
| 		} | ||||
| 		serializedProfile := "serialized-profile" | ||||
| 		s.Serializer.On("Serialize", profile).Return(serializedProfile, nil) | ||||
|  | ||||
| 		err := suite.Redis.StoreMojangUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd") | ||||
| 		suite.Require().Nil(err) | ||||
| 		s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile) | ||||
| 		s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") | ||||
|  | ||||
| 		resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock") | ||||
| 		suite.Require().Equal(resp, "Mock:d3ca513eb3e14946b58047f2bd3530fd:1587435016") | ||||
| 		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("store empty uuid", func() { | ||||
| 		now = func() time.Time { | ||||
| 			return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC) | ||||
| 	s.Run("update exists record with changed username", func() { | ||||
| 		newProfile := &db.Profile{ | ||||
| 			Uuid:     "f57f36d5-4f50-4728-948a-42d5d80b18f3", | ||||
| 			Username: "NewMock", | ||||
| 		} | ||||
| 		serializedNewProfile := "serialized-new-profile" | ||||
| 		s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil) | ||||
|  | ||||
| 		err := suite.Redis.StoreMojangUuid("Mock", "") | ||||
| 		suite.Require().Nil(err) | ||||
| 		s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile") | ||||
| 		s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") | ||||
|  | ||||
| 		resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock") | ||||
| 		suite.Require().Equal(resp, "Mock::1587435016") | ||||
| 		err := s.Redis.SaveProfile(newProfile) | ||||
| 		s.Require().NoError(err) | ||||
|  | ||||
| 		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) TestPing() { | ||||
| 	err := suite.Redis.Ping() | ||||
| 	suite.Require().Nil(err) | ||||
| func (s *redisTestSuite) TestRemoveProfileByUuid() { | ||||
| 	s.Run("exists record", func() { | ||||
| 		s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile") | ||||
| 		s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") | ||||
|  | ||||
| 		err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") | ||||
| 		s.Require().NoError(err) | ||||
|  | ||||
| 		uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3") | ||||
| 		s.Require().Empty(uuidResp) | ||||
|  | ||||
| 		profileResp := s.cmd("HGET", usernameToProfileKey, "mock") | ||||
| 		s.Require().Empty(profileResp) | ||||
| 	}) | ||||
|  | ||||
| 	s.Run("uuid exists, username is missing", func() { | ||||
| 		s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") | ||||
|  | ||||
| 		err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") | ||||
| 		s.Require().NoError(err) | ||||
|  | ||||
| 		uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3") | ||||
| 		s.Require().Empty(uuidResp) | ||||
| 	}) | ||||
|  | ||||
| 	s.Run("uuid not exists", func() { | ||||
| 		err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") | ||||
| 		s.Require().NoError(err) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (s *redisTestSuite) TestGetUuidForMojangUsername() { | ||||
| 	s.Run("exists record", func() { | ||||
| 		s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd") | ||||
|  | ||||
| 		uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock") | ||||
| 		s.Require().NoError(err) | ||||
| 		s.Require().Equal("MoCk", username) | ||||
| 		s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid) | ||||
| 	}) | ||||
|  | ||||
| 	s.Run("exists record with empty uuid value", func() { | ||||
| 		s.cmd("SET", "mojang:uuid:mock", "MoCk:") | ||||
|  | ||||
| 		uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock") | ||||
| 		s.Require().NoError(err) | ||||
| 		s.Require().Equal("MoCk", username) | ||||
| 		s.Require().Empty(uuid) | ||||
| 	}) | ||||
|  | ||||
| 	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 (s *redisTestSuite) TestStoreUuid() { | ||||
| 	s.Run("store uuid", func() { | ||||
| 		err := s.Redis.StoreMojangUuid("MoCk", "d3ca513eb3e14946b58047f2bd3530fd") | ||||
| 		s.Require().NoError(err) | ||||
|  | ||||
| 		resp := s.cmd("GET", "mojang:uuid:mock") | ||||
| 		s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd") | ||||
| 	}) | ||||
|  | ||||
| 	s.Run("store empty uuid", func() { | ||||
| 		err := s.Redis.StoreMojangUuid("MoCk", "") | ||||
| 		s.Require().NoError(err) | ||||
|  | ||||
| 		resp := s.cmd("GET", "mojang:uuid:mock") | ||||
| 		s.Require().Equal(resp, "MoCk:") | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func (s *redisTestSuite) TestPing() { | ||||
| 	err := s.Redis.Ping() | ||||
| 	s.Require().Nil(err) | ||||
| } | ||||
|   | ||||
							
								
								
									
										136
									
								
								db/serializer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								db/serializer.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										194
									
								
								db/serializer_test.go
									
									
									
									
									
										Normal 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) | ||||
| 		} | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										22
									
								
								di/db.go
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								di/db.go
									
									
									
									
									
								
							| @@ -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"), | ||||
| 	)) | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								di/di.go
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								di/di.go
									
									
									
									
									
								
							| @@ -10,6 +10,7 @@ func New() (*di.Container, error) { | ||||
| 		db, | ||||
| 		mojangTextures, | ||||
| 		handlers, | ||||
| 		profilesDi, | ||||
| 		server, | ||||
| 		signer, | ||||
| 	) | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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
									
								
							
							
						
						
									
										27
									
								
								di/profiles.go
									
									
									
									
									
										Normal 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
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								go.mod
									
									
									
									
									
								
							| @@ -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
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								go.sum
									
									
									
									
									
								
							| @@ -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= | ||||
|   | ||||
							
								
								
									
										192
									
								
								http/api.go
									
									
									
									
									
								
							
							
						
						
									
										192
									
								
								http/api.go
									
									
									
									
									
								
							| @@ -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") | ||||
|  | ||||
| 	record, err := ctx.findIdentityOrCleanup(identityId, username) | ||||
| 	if err != nil { | ||||
| 		panic(err) | ||||
| 	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"), | ||||
| 	} | ||||
|  | ||||
| 	if record == nil { | ||||
| 		record = &model.Skin{ | ||||
| 			UserId:   identityId, | ||||
| 			Username: username, | ||||
| 	err = ctx.PersistProfile(profile) | ||||
| 	if err != nil { | ||||
| 		var v *profiles.ValidationError | ||||
| 		if errors.As(err, &v) { | ||||
| 			apiBadRequest(resp, v.Errors) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	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 | ||||
| } | ||||
|   | ||||
							
								
								
									
										450
									
								
								http/api_test.go
									
									
									
									
									
								
							
							
						
						
									
										450
									
								
								http/api_test.go
									
									
									
									
									
								
							| @@ -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"}, | ||||
| 			"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"}, | ||||
| 		req := httptest.NewRequest("POST", "http://chrly/profiles", bytes.NewBufferString(url.Values{ | ||||
| 			"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"}, | ||||
| 			"username":        {"mock_username"}, | ||||
| 			"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 * | ||||
|  **************************************/ | ||||
| 	t.Run("receive validation errors", func() { | ||||
| 		t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(&profiles.ValidationError{ | ||||
| 			Errors: map[string][]string{ | ||||
| 				"mock": {"error1", "error2"}, | ||||
| 			}, | ||||
| 		}) | ||||
|  | ||||
| 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) | ||||
| 		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(204, resp.StatusCode) | ||||
| 		body, _ := ioutil.ReadAll(resp.Body) | ||||
| 		suite.Empty(body) | ||||
| 		t.Equal(http.StatusBadRequest, result.StatusCode) | ||||
| 		body, _ := io.ReadAll(result.Body) | ||||
| 		t.JSONEq(`{ | ||||
| 			"errors": { | ||||
| 				"mock": [ | ||||
| 					"error1", | ||||
| 					"error2" | ||||
| 				] | ||||
| 			} | ||||
| 		}`, string(body)) | ||||
| 	}) | ||||
|  | ||||
| 	suite.RunSubTest("Try to remove not exists identity id", func() { | ||||
| 		suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil) | ||||
| 	t.Run("receive other error", func() { | ||||
| 		t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(errors.New("mock error")) | ||||
|  | ||||
| 		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.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)) | ||||
| } | ||||
|   | ||||
							
								
								
									
										10
									
								
								http/http.go
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								http/http.go
									
									
									
									
									
								
							| @@ -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") | ||||
|   | ||||
| @@ -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
											
										
									
								
							
							
								
								
									
										122
									
								
								internal/profiles/manager.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										122
									
								
								internal/profiles/manager.go
									
									
									
									
									
										Normal 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" | ||||
| } | ||||
							
								
								
									
										138
									
								
								internal/profiles/manager_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								internal/profiles/manager_test.go
									
									
									
									
									
										Normal 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)) | ||||
| } | ||||
							
								
								
									
										88
									
								
								internal/profiles/provider.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								internal/profiles/provider.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										272
									
								
								internal/profiles/provider_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										272
									
								
								internal/profiles/provider_test.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
| @@ -1,9 +0,0 @@ | ||||
| package model | ||||
|  | ||||
| import ( | ||||
| 	"io" | ||||
| ) | ||||
|  | ||||
| type Cape struct { | ||||
| 	File io.Reader | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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{}, | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
|   | ||||
| @@ -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++ { | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 | ||||
| 	} | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user