mirror of
https://github.com/elyby/chrly.git
synced 2025-03-13 23:34:23 +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:
parent
dac5e4967f
commit
dac3ca9001
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,24 +1,25 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mediocregopher/radix/v4"
|
||||
|
||||
"github.com/elyby/chrly/model"
|
||||
"github.com/elyby/chrly/db"
|
||||
)
|
||||
|
||||
var now = time.Now
|
||||
const usernameToProfileKey = "hash:username-to-profile"
|
||||
const userUuidToUsernameKey = "hash:uuid-to-username"
|
||||
|
||||
func New(ctx context.Context, addr string, poolSize int) (*Redis, error) {
|
||||
type Redis struct {
|
||||
client radix.Client
|
||||
context context.Context
|
||||
serializer db.ProfileSerializer
|
||||
}
|
||||
|
||||
func New(ctx context.Context, profileSerializer db.ProfileSerializer, addr string, poolSize int) (*Redis, error) {
|
||||
client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -27,33 +28,25 @@ func New(ctx context.Context, addr string, poolSize int) (*Redis, error) {
|
||||
return &Redis{
|
||||
client: client,
|
||||
context: ctx,
|
||||
serializer: profileSerializer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username"
|
||||
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
|
||||
|
||||
type Redis struct {
|
||||
client radix.Client
|
||||
context context.Context
|
||||
}
|
||||
|
||||
func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) {
|
||||
var skin *model.Skin
|
||||
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
func (r *Redis) FindProfileByUsername(username string) (*db.Profile, error) {
|
||||
var profile *db.Profile
|
||||
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
skin, err = findByUsername(ctx, conn, username)
|
||||
profile, err = r.findProfileByUsername(ctx, conn, username)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return skin, err
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func findByUsername(ctx context.Context, conn radix.Conn, username string) (*model.Skin, error) {
|
||||
redisKey := buildUsernameKey(username)
|
||||
func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) {
|
||||
var encodedResult []byte
|
||||
err := conn.Do(ctx, radix.Cmd(&encodedResult, "GET", redisKey))
|
||||
err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -62,33 +55,14 @@ func findByUsername(ctx context.Context, conn radix.Conn, username string) (*mod
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := zlibDecode(encodedResult)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return r.serializer.Deserialize(encodedResult)
|
||||
}
|
||||
|
||||
var skin *model.Skin
|
||||
err = json.Unmarshal(result, &skin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Some old data causing issues in the production.
|
||||
// TODO: remove after investigation will be finished
|
||||
if skin.Uuid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
skin.OldUsername = skin.Username
|
||||
|
||||
return skin, nil
|
||||
}
|
||||
|
||||
func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
|
||||
var skin *model.Skin
|
||||
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
func (r *Redis) FindProfileByUuid(uuid string) (*db.Profile, error) {
|
||||
var skin *db.Profile
|
||||
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
skin, err = findByUserId(ctx, conn, id)
|
||||
skin, err = r.findProfileByUuid(ctx, conn, uuid)
|
||||
|
||||
return err
|
||||
}))
|
||||
@ -96,9 +70,8 @@ func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
|
||||
return skin, err
|
||||
}
|
||||
|
||||
func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, error) {
|
||||
var username string
|
||||
err := conn.Do(ctx, radix.FlatCmd(&username, "HGET", accountIdToUsernameKey, id))
|
||||
func (r *Redis) findProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) (*db.Profile, error) {
|
||||
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -107,36 +80,51 @@ func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, er
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return findByUsername(ctx, conn, username)
|
||||
return r.findProfileByUsername(ctx, conn, username)
|
||||
}
|
||||
|
||||
func (db *Redis) SaveSkin(skin *model.Skin) error {
|
||||
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return save(ctx, conn, skin)
|
||||
func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) {
|
||||
var username string
|
||||
return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid)))
|
||||
}
|
||||
|
||||
func (r *Redis) SaveProfile(profile *db.Profile) error {
|
||||
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return r.saveProfile(ctx, conn, profile)
|
||||
}))
|
||||
}
|
||||
|
||||
func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error {
|
||||
err := conn.Do(ctx, radix.Cmd(nil, "MULTI"))
|
||||
func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error {
|
||||
newUsernameHashKey := usernameHashKey(profile.Username)
|
||||
existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If user has changed username, then we must delete his old username record
|
||||
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(skin.OldUsername)))
|
||||
if existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a new record or if the user has changed username, we set the value in the hash table
|
||||
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", accountIdToUsernameKey, skin.UserId, skin.Username))
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
str, _ := json.Marshal(skin)
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "SET", buildUsernameKey(skin.Username), zlibEncode(str)))
|
||||
serializedProfile, err := r.serializer.Serialize(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -146,19 +134,17 @@ func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error {
|
||||
return err
|
||||
}
|
||||
|
||||
skin.OldUsername = skin.Username
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Redis) RemoveSkinByUserId(id int) error {
|
||||
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return removeByUserId(ctx, conn, id)
|
||||
func (r *Redis) RemoveProfileByUuid(uuid string) error {
|
||||
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return r.removeProfileByUuid(ctx, conn, uuid)
|
||||
}))
|
||||
}
|
||||
|
||||
func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
|
||||
record, err := findByUserId(ctx, conn, id)
|
||||
func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error {
|
||||
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -168,13 +154,13 @@ func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, id))
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", userUuidToUsernameKey, normalizeUuid(uuid)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record != nil {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username)))
|
||||
if username != "" {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -183,44 +169,10 @@ func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
|
||||
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
}
|
||||
|
||||
func (db *Redis) RemoveSkinByUsername(username string) error {
|
||||
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return removeByUsername(ctx, conn, username)
|
||||
}))
|
||||
}
|
||||
|
||||
func removeByUsername(ctx context.Context, conn radix.Conn, username string) error {
|
||||
record, err := findByUsername(ctx, conn, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, record.UserId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
}
|
||||
|
||||
func (db *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
|
||||
func (r *Redis) GetUuidForMojangUsername(username string) (string, string, error) {
|
||||
var uuid string
|
||||
foundUsername := username
|
||||
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
|
||||
|
||||
@ -231,9 +183,9 @@ func (db *Redis) GetUuidForMojangUsername(username string) (string, string, erro
|
||||
}
|
||||
|
||||
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
|
||||
key := strings.ToLower(username)
|
||||
key := buildMojangUsernameKey(username)
|
||||
var result string
|
||||
err := conn.Do(ctx, radix.Cmd(&result, "HGET", mojangUsernameToUuidKey, key))
|
||||
err := conn.Do(ctx, radix.Cmd(&result, "GET", key))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@ -243,51 +195,19 @@ func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username str
|
||||
}
|
||||
|
||||
parts := strings.Split(result, ":")
|
||||
partsCnt := len(parts)
|
||||
// https://github.com/elyby/chrly/issues/28
|
||||
if partsCnt < 2 {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
|
||||
return parts[1], parts[0], nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result)
|
||||
}
|
||||
|
||||
var casedUsername, uuid, rawTimestamp string
|
||||
if partsCnt == 2 { // Legacy, when original username wasn't stored
|
||||
casedUsername = username
|
||||
uuid = parts[0]
|
||||
rawTimestamp = parts[1]
|
||||
} else {
|
||||
casedUsername = parts[0]
|
||||
uuid = parts[1]
|
||||
rawTimestamp = parts[2]
|
||||
}
|
||||
|
||||
timestamp, _ := strconv.ParseInt(rawTimestamp, 10, 64)
|
||||
storedAt := time.Unix(timestamp, 0)
|
||||
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
return uuid, casedUsername, nil
|
||||
}
|
||||
|
||||
func (db *Redis) StoreMojangUuid(username string, uuid string) error {
|
||||
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
func (r *Redis) StoreMojangUuid(username string, uuid string) error {
|
||||
return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return storeMojangUuid(ctx, conn, username, uuid)
|
||||
}))
|
||||
}
|
||||
|
||||
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
|
||||
value := fmt.Sprintf("%s:%s:%d", username, uuid, now().Unix())
|
||||
err := conn.Do(ctx, radix.Cmd(nil, "HSET", mojangUsernameToUuidKey, strings.ToLower(username), value))
|
||||
value := fmt.Sprintf("%s:%s", username, uuid)
|
||||
err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -295,33 +215,18 @@ func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Redis) Ping() error {
|
||||
return db.client.Do(db.context, radix.Cmd(nil, "PING"))
|
||||
func (r *Redis) Ping() error {
|
||||
return r.client.Do(r.context, radix.Cmd(nil, "PING"))
|
||||
}
|
||||
|
||||
func buildUsernameKey(username string) string {
|
||||
return "username:" + strings.ToLower(username)
|
||||
func normalizeUuid(uuid string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(uuid, "-", ""))
|
||||
}
|
||||
|
||||
func zlibEncode(str []byte) []byte {
|
||||
var buff bytes.Buffer
|
||||
writer := zlib.NewWriter(&buff)
|
||||
_, _ = writer.Write(str)
|
||||
_ = writer.Close()
|
||||
|
||||
return buff.Bytes()
|
||||
func usernameHashKey(username string) string {
|
||||
return strings.ToLower(username)
|
||||
}
|
||||
|
||||
func zlibDecode(bts []byte) ([]byte, error) {
|
||||
buff := bytes.NewReader(bts)
|
||||
reader, err := zlib.NewReader(buff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultBuffer := new(bytes.Buffer)
|
||||
_, _ = io.Copy(resultBuffer, reader)
|
||||
_ = reader.Close()
|
||||
|
||||
return resultBuffer.Bytes(), nil
|
||||
func buildMojangUsernameKey(username string) string {
|
||||
return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username))
|
||||
}
|
||||
|
@ -4,17 +4,18 @@ package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mediocregopher/radix/v4"
|
||||
"github.com/stretchr/testify/mock"
|
||||
assert "github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"github.com/elyby/chrly/model"
|
||||
"github.com/elyby/chrly/db"
|
||||
)
|
||||
|
||||
var redisAddr string
|
||||
@ -33,15 +34,35 @@ func init() {
|
||||
redisAddr = fmt.Sprintf("%s:%d", host, port)
|
||||
}
|
||||
|
||||
type MockProfileSerializer struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) {
|
||||
args := m.Called(profile)
|
||||
|
||||
return []byte(args.String(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) {
|
||||
args := m.Called(value)
|
||||
var result *db.Profile
|
||||
if casted, ok := args.Get(0).(*db.Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("should connect", func(t *testing.T) {
|
||||
conn, err := New(context.Background(), redisAddr, 12)
|
||||
conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
})
|
||||
|
||||
t.Run("should return error", func(t *testing.T) {
|
||||
conn, err := New(context.Background(), "localhost:12345", 12) // Use localhost to avoid DNS resolution
|
||||
conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, conn)
|
||||
})
|
||||
@ -51,21 +72,24 @@ type redisTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Redis *Redis
|
||||
Serializer *MockProfileSerializer
|
||||
|
||||
cmd func(cmd string, args ...interface{}) string
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) SetupSuite() {
|
||||
func (s *redisTestSuite) SetupSuite() {
|
||||
s.Serializer = &MockProfileSerializer{}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := New(ctx, redisAddr, 10)
|
||||
conn, err := New(ctx, s.Serializer, redisAddr, 10)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
|
||||
}
|
||||
|
||||
suite.Redis = conn
|
||||
suite.cmd = func(cmd string, args ...interface{}) string {
|
||||
s.Redis = conn
|
||||
s.cmd = func(cmd string, args ...interface{}) string {
|
||||
var result string
|
||||
err := suite.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
|
||||
err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -74,365 +98,202 @@ func (suite *redisTestSuite) SetupSuite() {
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) SetupTest() {
|
||||
func (s *redisTestSuite) SetupSubTest() {
|
||||
// Cleanup database before each test
|
||||
suite.cmd("FLUSHALL")
|
||||
s.cmd("FLUSHALL")
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TearDownTest() {
|
||||
// Restore time.Now func
|
||||
now = time.Now
|
||||
func (s *redisTestSuite) TearDownSubTest() {
|
||||
s.Serializer.AssertExpectations(s.T())
|
||||
for _, call := range s.Serializer.ExpectedCalls {
|
||||
call.Unset()
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) RunSubTest(name string, subTest func()) {
|
||||
suite.SetupTest()
|
||||
suite.Run(name, subTest)
|
||||
}
|
||||
|
||||
func TestRedis(t *testing.T) {
|
||||
suite.Run(t, new(redisTestSuite))
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON with zlib encoding
|
||||
* {
|
||||
* userId: 1,
|
||||
* uuid: "fd5da1e4d66d4d17aadee2446093896d",
|
||||
* username: "Mock",
|
||||
* skinId: 1,
|
||||
* url: "http://localhost/skin.png",
|
||||
* is1_8: true,
|
||||
* isSlim: false,
|
||||
* mojangTextures: "mock-mojang-textures",
|
||||
* mojangSignature: "mock-mojang-signature"
|
||||
* }
|
||||
*/
|
||||
var skinRecord = string([]byte{
|
||||
0x78, 0x9c, 0x5c, 0xce, 0x4b, 0x4a, 0x4, 0x41, 0xc, 0xc6, 0xf1, 0xbb, 0x7c, 0xeb, 0x1a, 0xdb, 0xd6, 0xb2,
|
||||
0x9c, 0xc9, 0xd, 0x5c, 0x88, 0x8b, 0xd1, 0xb5, 0x84, 0x4e, 0xa6, 0xa7, 0xec, 0x7a, 0xc, 0xf5, 0x0, 0x41,
|
||||
0xbc, 0xbb, 0xb4, 0xd2, 0xa, 0x2e, 0xf3, 0xe3, 0x9f, 0x90, 0xf, 0xf4, 0xaa, 0xe5, 0x41, 0x40, 0xa3, 0x41,
|
||||
0xef, 0x5e, 0x40, 0x38, 0xc9, 0x9d, 0xf0, 0xa8, 0x56, 0x9c, 0x13, 0x2b, 0xe3, 0x3d, 0xb3, 0xa8, 0xde, 0x58,
|
||||
0xeb, 0xae, 0xf, 0xb7, 0xfb, 0x83, 0x13, 0x98, 0xef, 0xa5, 0xc4, 0x51, 0x41, 0x78, 0xcc, 0xd3, 0x2, 0x83,
|
||||
0xba, 0xf8, 0xb4, 0x9d, 0x29, 0x1, 0x84, 0x73, 0x6b, 0x17, 0x1a, 0x86, 0x90, 0x27, 0xe, 0xe7, 0x5c, 0xdb,
|
||||
0xb0, 0x16, 0x57, 0x97, 0x34, 0xc3, 0xc0, 0xd7, 0xf1, 0x75, 0xf, 0x6a, 0xa5, 0xeb, 0x3a, 0x1c, 0x83, 0x8f,
|
||||
0xa0, 0x13, 0x87, 0xaa, 0x6, 0x31, 0xbf, 0x71, 0x9a, 0x9f, 0xf5, 0xbd, 0xf5, 0xa2, 0x15, 0x84, 0x98, 0xa7,
|
||||
0x65, 0xf7, 0xa3, 0xbb, 0xb6, 0xf1, 0xd6, 0x1d, 0xfd, 0x9c, 0x78, 0xa5, 0x7f, 0x61, 0xfd, 0x75, 0x83, 0xa7,
|
||||
0x20, 0x2f, 0x7f, 0xff, 0xe2, 0xf3, 0x2b, 0x0, 0x0, 0xff, 0xff, 0x6f, 0xdd, 0x51, 0x71,
|
||||
func (s *redisTestSuite) TestFindProfileByUsername() {
|
||||
s.Run("exists record", func() {
|
||||
serializedData := []byte("mock.exists.profile")
|
||||
expectedProfile := &db.Profile{}
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
|
||||
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
|
||||
|
||||
profile, err := s.Redis.FindProfileByUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(expectedProfile, profile)
|
||||
})
|
||||
|
||||
func (suite *redisTestSuite) TestFindSkinByUsername() {
|
||||
suite.RunSubTest("exists record", func() {
|
||||
suite.cmd("SET", "username:mock", skinRecord)
|
||||
|
||||
skin, err := suite.Redis.FindSkinByUsername("Mock")
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().NotNil(skin)
|
||||
suite.Require().Equal(1, skin.UserId)
|
||||
suite.Require().Equal("fd5da1e4d66d4d17aadee2446093896d", skin.Uuid)
|
||||
suite.Require().Equal("Mock", skin.Username)
|
||||
suite.Require().Equal(1, skin.SkinId)
|
||||
suite.Require().Equal("http://localhost/skin.png", skin.Url)
|
||||
suite.Require().True(skin.Is1_8)
|
||||
suite.Require().False(skin.IsSlim)
|
||||
suite.Require().Equal("mock-mojang-textures", skin.MojangTextures)
|
||||
suite.Require().Equal("mock-mojang-signature", skin.MojangSignature)
|
||||
suite.Require().Equal(skin.Username, skin.OldUsername)
|
||||
s.Run("not exists record", func() {
|
||||
profile, err := s.Redis.FindProfileByUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
|
||||
suite.RunSubTest("not exists record", func() {
|
||||
skin, err := suite.Redis.FindSkinByUsername("Mock")
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().Nil(skin)
|
||||
})
|
||||
s.Run("an error from serializer implementation", func() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data")
|
||||
s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError)
|
||||
|
||||
suite.RunSubTest("invalid zlib encoding", func() {
|
||||
suite.cmd("SET", "username:mock", "this is really not zlib")
|
||||
skin, err := suite.Redis.FindSkinByUsername("Mock")
|
||||
suite.Require().Nil(skin)
|
||||
suite.Require().EqualError(err, "zlib: invalid header")
|
||||
})
|
||||
|
||||
suite.RunSubTest("invalid json encoding", func() {
|
||||
suite.cmd("SET", "username:mock", []byte{
|
||||
0x78, 0x9c, 0xca, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x28, 0xcf, 0x2f, 0xca, 0x49, 0x1, 0x4, 0x0, 0x0, 0xff,
|
||||
0xff, 0x1a, 0xb, 0x4, 0x5d,
|
||||
})
|
||||
skin, err := suite.Redis.FindSkinByUsername("Mock")
|
||||
suite.Require().Nil(skin)
|
||||
suite.Require().EqualError(err, "invalid character 'h' looking for beginning of value")
|
||||
profile, err := s.Redis.FindProfileByUsername("Mock")
|
||||
s.Require().Nil(profile)
|
||||
s.Require().ErrorIs(err, expectedError)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestFindSkinByUserId() {
|
||||
suite.RunSubTest("exists record", func() {
|
||||
suite.cmd("SET", "username:mock", skinRecord)
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
func (s *redisTestSuite) TestFindProfileByUuid() {
|
||||
s.Run("exists record", func() {
|
||||
serializedData := []byte("mock.exists.profile")
|
||||
expectedProfile := &db.Profile{Username: "Mock"}
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
|
||||
|
||||
skin, err := suite.Redis.FindSkinByUserId(1)
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().NotNil(skin)
|
||||
suite.Require().Equal(1, skin.UserId)
|
||||
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(expectedProfile, profile)
|
||||
})
|
||||
|
||||
suite.RunSubTest("not exists record", func() {
|
||||
skin, err := suite.Redis.FindSkinByUserId(1)
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().Nil(skin)
|
||||
s.Run("not exists record", func() {
|
||||
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists hash record, but no skin record", func() {
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
skin, err := suite.Redis.FindSkinByUserId(1)
|
||||
suite.Require().Nil(err)
|
||||
suite.Require().Nil(skin)
|
||||
s.Run("exists uuid record, but related profile not exists", func() {
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestSaveSkin() {
|
||||
suite.RunSubTest("save new entity", func() {
|
||||
err := suite.Redis.SaveSkin(&model.Skin{
|
||||
UserId: 1,
|
||||
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
|
||||
func (s *redisTestSuite) TestSaveProfile() {
|
||||
s.Run("save new entity", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
|
||||
Username: "Mock",
|
||||
SkinId: 1,
|
||||
Url: "http://localhost/skin.png",
|
||||
Is1_8: true,
|
||||
IsSlim: false,
|
||||
MojangTextures: "mock-mojang-textures",
|
||||
MojangSignature: "mock-mojang-signature",
|
||||
})
|
||||
suite.Require().Nil(err)
|
||||
}
|
||||
serializedProfile := "serialized-profile"
|
||||
s.Serializer.On("Serialize", profile).Return(serializedProfile, nil)
|
||||
|
||||
usernameResp := suite.cmd("GET", "username:mock")
|
||||
suite.Require().NotEmpty(usernameResp)
|
||||
suite.Require().Equal(skinRecord, usernameResp)
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile)
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
|
||||
suite.Require().Equal("Mock", idResp)
|
||||
err := s.Redis.SaveProfile(profile)
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Equal("mock", uuidResp)
|
||||
|
||||
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Equal(serializedProfile, profileResp)
|
||||
})
|
||||
|
||||
suite.RunSubTest("save exists record with changed username", func() {
|
||||
suite.cmd("SET", "username:mock", skinRecord)
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
|
||||
err := suite.Redis.SaveSkin(&model.Skin{
|
||||
UserId: 1,
|
||||
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
|
||||
s.Run("update exists record with changed username", func() {
|
||||
newProfile := &db.Profile{
|
||||
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
|
||||
Username: "NewMock",
|
||||
SkinId: 1,
|
||||
Url: "http://localhost/skin.png",
|
||||
Is1_8: true,
|
||||
IsSlim: false,
|
||||
MojangTextures: "mock-mojang-textures",
|
||||
MojangSignature: "mock-mojang-signature",
|
||||
OldUsername: "Mock",
|
||||
})
|
||||
suite.Require().Nil(err)
|
||||
}
|
||||
serializedNewProfile := "serialized-new-profile"
|
||||
s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil)
|
||||
|
||||
usernameResp := suite.cmd("GET", "username:newmock")
|
||||
suite.Require().NotEmpty(usernameResp)
|
||||
suite.Require().Equal(string([]byte{
|
||||
0x78, 0x9c, 0x5c, 0x8e, 0xcb, 0x4e, 0xc3, 0x40, 0xc, 0x45, 0xff, 0xe5, 0xae, 0xa7, 0x84, 0x40, 0x18, 0x5a,
|
||||
0xff, 0x1, 0xb, 0x60, 0x51, 0x58, 0x23, 0x2b, 0x76, 0xd3, 0x21, 0xf3, 0xa8, 0xe6, 0x21, 0x90, 0x10, 0xff,
|
||||
0x8e, 0x52, 0x14, 0x90, 0xba, 0xf4, 0xd1, 0xf1, 0xd5, 0xf9, 0x42, 0x2b, 0x9a, 0x1f, 0x4, 0xd4, 0x1b, 0xb4,
|
||||
0xe6, 0x4, 0x84, 0x83, 0xdc, 0x9, 0xf7, 0x3a, 0x88, 0xb5, 0x32, 0x48, 0x7f, 0xcf, 0x2c, 0xaa, 0x37, 0xc3,
|
||||
0x60, 0xaf, 0x77, 0xb7, 0xdb, 0x9d, 0x15, 0x98, 0xf3, 0x53, 0xe4, 0xa0, 0x20, 0x3c, 0xe9, 0xc7, 0x63, 0x1a,
|
||||
0x67, 0x18, 0x94, 0xd9, 0xc5, 0x75, 0x29, 0x7b, 0x10, 0x8e, 0xb5, 0x9e, 0xa8, 0xeb, 0x7c, 0x1a, 0xd9, 0x1f,
|
||||
0x53, 0xa9, 0xdd, 0x62, 0x5c, 0x9d, 0xe2, 0x4, 0x3, 0x57, 0xfa, 0xb7, 0x2d, 0xa8, 0xe6, 0xa6, 0xcb, 0xb1,
|
||||
0xf7, 0x2e, 0x80, 0xe, 0xec, 0x8b, 0x1a, 0x84, 0xf4, 0xce, 0x71, 0x7a, 0xd1, 0xcf, 0xda, 0xb2, 0x16, 0x10,
|
||||
0x42, 0x1a, 0xe7, 0xcd, 0x2f, 0xdd, 0xd4, 0x15, 0xaf, 0xde, 0xde, 0x4d, 0x91, 0x17, 0x74, 0x21, 0x96, 0x3f,
|
||||
0x6e, 0xf0, 0xec, 0xe5, 0xf5, 0x3f, 0xf9, 0xdc, 0xfb, 0xfd, 0x13, 0x0, 0x0, 0xff, 0xff, 0xca, 0xc3, 0x54,
|
||||
0x25,
|
||||
}), usernameResp)
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile")
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
oldUsernameResp := suite.cmd("GET", "username:mock")
|
||||
suite.Require().Empty(oldUsernameResp)
|
||||
err := s.Redis.SaveProfile(newProfile)
|
||||
s.Require().NoError(err)
|
||||
|
||||
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
|
||||
suite.Require().NotEmpty(usernameResp)
|
||||
suite.Require().Equal("NewMock", idResp)
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Equal("newmock", uuidResp)
|
||||
|
||||
newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock")
|
||||
s.Require().Equal(serializedNewProfile, newProfileResp)
|
||||
|
||||
oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Empty(oldProfileResp)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestRemoveSkinByUserId() {
|
||||
suite.RunSubTest("exists record", func() {
|
||||
suite.cmd("SET", "username:mock", skinRecord)
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
func (s *redisTestSuite) TestRemoveProfileByUuid() {
|
||||
s.Run("exists record", func() {
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile")
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := suite.Redis.RemoveSkinByUserId(1)
|
||||
suite.Require().Nil(err)
|
||||
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
|
||||
usernameResp := suite.cmd("GET", "username:mock")
|
||||
suite.Require().Empty(usernameResp)
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Empty(uuidResp)
|
||||
|
||||
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
|
||||
suite.Require().Empty(idResp)
|
||||
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Empty(profileResp)
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists only id", func() {
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
s.Run("uuid exists, username is missing", func() {
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := suite.Redis.RemoveSkinByUserId(1)
|
||||
suite.Require().Nil(err)
|
||||
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
|
||||
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
|
||||
suite.Require().Empty(idResp)
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Empty(uuidResp)
|
||||
})
|
||||
|
||||
suite.RunSubTest("error when querying skin record", func() {
|
||||
suite.cmd("SET", "username:mock", "invalid zlib")
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
|
||||
err := suite.Redis.RemoveSkinByUserId(1)
|
||||
suite.Require().EqualError(err, "zlib: invalid header")
|
||||
s.Run("uuid not exists", func() {
|
||||
err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestRemoveSkinByUsername() {
|
||||
suite.RunSubTest("exists record", func() {
|
||||
suite.cmd("SET", "username:mock", skinRecord)
|
||||
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
|
||||
func (s *redisTestSuite) TestGetUuidForMojangUsername() {
|
||||
s.Run("exists record", func() {
|
||||
s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
|
||||
|
||||
err := suite.Redis.RemoveSkinByUsername("Mock")
|
||||
suite.Require().Nil(err)
|
||||
|
||||
usernameResp := suite.cmd("GET", "username:mock")
|
||||
suite.Require().Empty(usernameResp)
|
||||
|
||||
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
|
||||
suite.Require().Empty(idResp)
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("MoCk", username)
|
||||
s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists only username", func() {
|
||||
suite.cmd("SET", "username:mock", skinRecord)
|
||||
s.Run("exists record with empty uuid value", func() {
|
||||
s.cmd("SET", "mojang:uuid:mock", "MoCk:")
|
||||
|
||||
err := suite.Redis.RemoveSkinByUsername("Mock")
|
||||
suite.Require().Nil(err)
|
||||
|
||||
usernameResp := suite.cmd("GET", "username:mock")
|
||||
suite.Require().Empty(usernameResp)
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("MoCk", username)
|
||||
s.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("no records", func() {
|
||||
err := suite.Redis.RemoveSkinByUsername("Mock")
|
||||
suite.Require().Nil(err)
|
||||
})
|
||||
|
||||
suite.RunSubTest("error when querying skin record", func() {
|
||||
suite.cmd("SET", "username:mock", "invalid zlib")
|
||||
|
||||
err := suite.Redis.RemoveSkinByUsername("Mock")
|
||||
suite.Require().EqualError(err, "zlib: invalid header")
|
||||
s.Run("not exists record", func() {
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Empty(username)
|
||||
s.Require().Empty(uuid)
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestGetUuid() {
|
||||
suite.RunSubTest("exists record", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
|
||||
)
|
||||
func (s *redisTestSuite) TestStoreUuid() {
|
||||
s.Run("store uuid", func() {
|
||||
err := s.Redis.StoreMojangUuid("MoCk", "d3ca513eb3e14946b58047f2bd3530fd")
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("MoCk", username)
|
||||
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
resp := s.cmd("GET", "mojang:uuid:mock")
|
||||
s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists record (legacy data)", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
|
||||
)
|
||||
s.Run("store empty uuid", func() {
|
||||
err := s.Redis.StoreMojangUuid("MoCk", "")
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("Mock", username)
|
||||
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists record with empty uuid value", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s::%d", "MoCk", time.Now().Unix()),
|
||||
)
|
||||
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Equal("MoCk", username)
|
||||
suite.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("not exists record", func() {
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Empty(username)
|
||||
suite.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists, but expired record", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
|
||||
)
|
||||
|
||||
uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().NoError(err)
|
||||
suite.Require().Empty(uuid)
|
||||
suite.Require().Empty(username)
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Empty(resp, "should cleanup expired records")
|
||||
})
|
||||
|
||||
suite.RunSubTest("exists, but corrupted record", func() {
|
||||
suite.cmd("HSET",
|
||||
"hash:mojang-username-to-uuid",
|
||||
"mock",
|
||||
"corrupted value",
|
||||
)
|
||||
|
||||
uuid, found, err := suite.Redis.GetUuidForMojangUsername("Mock")
|
||||
suite.Require().Empty(uuid)
|
||||
suite.Require().Empty(found)
|
||||
suite.Require().Error(err, "Got unexpected response from the mojangUsernameToUuid hash: \"corrupted value\"")
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Empty(resp, "should cleanup expired records")
|
||||
resp := s.cmd("GET", "mojang:uuid:mock")
|
||||
s.Require().Equal(resp, "MoCk:")
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestStoreUuid() {
|
||||
suite.RunSubTest("store uuid", func() {
|
||||
now = func() time.Time {
|
||||
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
|
||||
}
|
||||
|
||||
err := suite.Redis.StoreMojangUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
|
||||
suite.Require().Nil(err)
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Equal(resp, "Mock:d3ca513eb3e14946b58047f2bd3530fd:1587435016")
|
||||
})
|
||||
|
||||
suite.RunSubTest("store empty uuid", func() {
|
||||
now = func() time.Time {
|
||||
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
|
||||
}
|
||||
|
||||
err := suite.Redis.StoreMojangUuid("Mock", "")
|
||||
suite.Require().Nil(err)
|
||||
|
||||
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
|
||||
suite.Require().Equal(resp, "Mock::1587435016")
|
||||
})
|
||||
}
|
||||
|
||||
func (suite *redisTestSuite) TestPing() {
|
||||
err := suite.Redis.Ping()
|
||||
suite.Require().Nil(err)
|
||||
func (s *redisTestSuite) TestPing() {
|
||||
err := s.Redis.Ping()
|
||||
s.Require().Nil(err)
|
||||
}
|
||||
|
136
db/serializer.go
Normal file
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")
|
||||
profile := &db.Profile{
|
||||
Uuid: req.Form.Get("uuid"),
|
||||
Username: req.Form.Get("username"),
|
||||
SkinUrl: req.Form.Get("skinUrl"),
|
||||
SkinModel: req.Form.Get("skinModel"),
|
||||
CapeUrl: req.Form.Get("capeUrl"),
|
||||
MojangTextures: req.Form.Get("mojangTextures"),
|
||||
MojangSignature: req.Form.Get("mojangSignature"),
|
||||
}
|
||||
|
||||
record, err := ctx.findIdentityOrCleanup(identityId, username)
|
||||
err = ctx.PersistProfile(profile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
var v *profiles.ValidationError
|
||||
if errors.As(err, &v) {
|
||||
apiBadRequest(resp, v.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
if record == nil {
|
||||
record = &model.Skin{
|
||||
UserId: identityId,
|
||||
Username: username,
|
||||
}
|
||||
}
|
||||
|
||||
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
|
||||
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
|
||||
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
|
||||
|
||||
record.Uuid = strings.ToLower(req.Form.Get("uuid"))
|
||||
record.SkinId = skinId
|
||||
record.Is1_8 = is18
|
||||
record.IsSlim = isSlim
|
||||
record.Url = req.Form.Get("url")
|
||||
record.MojangTextures = req.Form.Get("mojangTextures")
|
||||
record.MojangSignature = req.Form.Get("mojangSignature")
|
||||
|
||||
err = ctx.SkinsRepo.SaveSkin(record)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
apiServerError(resp, "Unable to save profile to db", err)
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
id, _ := strconv.Atoi(mux.Vars(req)["id"])
|
||||
skin, err := ctx.SkinsRepo.FindSkinByUserId(id)
|
||||
ctx.deleteSkin(skin, err, resp)
|
||||
}
|
||||
|
||||
func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
username := mux.Vars(req)["username"]
|
||||
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
|
||||
ctx.deleteSkin(skin, err, resp)
|
||||
}
|
||||
|
||||
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
|
||||
func (ctx *Api) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
uuid := mux.Vars(req)["uuid"]
|
||||
err := ctx.ProfilesManager.RemoveProfileByUuid(uuid)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if skin == nil {
|
||||
apiNotFound(resp, "Cannot find record for the requested identifier")
|
||||
apiServerError(resp, "Unable to delete profile from db", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
resp.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) {
|
||||
record, err := ctx.SkinsRepo.FindSkinByUserId(identityId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if record != nil {
|
||||
// The username may have changed in the external database,
|
||||
// so we need to remove the old association
|
||||
if record.Username != username {
|
||||
_ = ctx.SkinsRepo.RemoveSkinByUserId(identityId)
|
||||
record.Username = username
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// If the requested id was not found, then username was reassigned to another user
|
||||
// who has not uploaded his data to Chrly yet
|
||||
record, err = ctx.SkinsRepo.FindSkinByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the target username does exist, clear it as it will be reassigned to the new user
|
||||
if record != nil {
|
||||
_ = ctx.SkinsRepo.RemoveSkinByUsername(username)
|
||||
record.UserId = identityId
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func validatePostSkinRequest(request *http.Request) map[string][]string {
|
||||
_ = request.ParseForm()
|
||||
|
||||
validationRules := govalidator.MapData{
|
||||
"identityId": {"required", "numeric", "min:1"},
|
||||
"username": {"required"},
|
||||
"uuid": {"required", "uuid_any"},
|
||||
"skinId": {"required", "numeric"},
|
||||
"url": {},
|
||||
"is1_8": {"bool"},
|
||||
"isSlim": {"bool"},
|
||||
"mojangTextures": {},
|
||||
"mojangSignature": {},
|
||||
}
|
||||
|
||||
url := request.Form.Get("url")
|
||||
if url == "" {
|
||||
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:0,0")
|
||||
} else {
|
||||
validationRules["url"] = append(validationRules["url"], "url")
|
||||
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:1,")
|
||||
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
|
||||
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
|
||||
}
|
||||
|
||||
mojangTextures := request.Form.Get("mojangTextures")
|
||||
if mojangTextures != "" {
|
||||
validationRules["mojangSignature"] = append(validationRules["mojangSignature"], "required")
|
||||
}
|
||||
|
||||
validator := govalidator.New(govalidator.Options{
|
||||
Request: request,
|
||||
Rules: validationRules,
|
||||
RequiredDefault: false,
|
||||
})
|
||||
validationResults := validator.Validate()
|
||||
|
||||
if len(validationResults) != 0 {
|
||||
return validationResults
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
460
http/api_test.go
460
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"},
|
||||
req := httptest.NewRequest("POST", "http://chrly/profiles", bytes.NewBufferString(url.Values{
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"username": {"mock_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
|
||||
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
|
||||
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
|
||||
suite.Equal(1, model.UserId)
|
||||
suite.Equal("mock_username", model.Username)
|
||||
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
|
||||
suite.Equal(5, model.SkinId)
|
||||
suite.False(model.Is1_8)
|
||||
suite.False(model.IsSlim)
|
||||
suite.Equal("http://example.com/skin.png", model.Url)
|
||||
|
||||
return true
|
||||
})).Times(1).Return(nil)
|
||||
},
|
||||
AfterTest: func(suite *apiTestSuite, response *http.Response) {
|
||||
suite.Equal(201, response.StatusCode)
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
suite.Empty(body)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Update exists identity by changing only textures data",
|
||||
Form: bytes.NewBufferString(url.Values{
|
||||
"identityId": {"1"},
|
||||
"username": {"mock_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"1"},
|
||||
"isSlim": {"1"},
|
||||
"url": {"http://textures-server.com/skin.png"},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
|
||||
suite.Equal(1, model.UserId)
|
||||
suite.Equal("mock_username", model.Username)
|
||||
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
|
||||
suite.Equal(5, model.SkinId)
|
||||
suite.True(model.Is1_8)
|
||||
suite.True(model.IsSlim)
|
||||
suite.Equal("http://textures-server.com/skin.png", model.Url)
|
||||
|
||||
return true
|
||||
})).Times(1).Return(nil)
|
||||
},
|
||||
AfterTest: func(suite *apiTestSuite, response *http.Response) {
|
||||
suite.Equal(201, response.StatusCode)
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
suite.Empty(body)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Update exists identity by changing textures data to empty",
|
||||
Form: bytes.NewBufferString(url.Values{
|
||||
"identityId": {"1"},
|
||||
"username": {"mock_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"0"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {""},
|
||||
"mojangTextures": {""},
|
||||
"mojangSignature": {""},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
|
||||
suite.Equal(1, model.UserId)
|
||||
suite.Equal("mock_username", model.Username)
|
||||
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
|
||||
suite.Equal(0, model.SkinId)
|
||||
suite.False(model.Is1_8)
|
||||
suite.False(model.IsSlim)
|
||||
suite.Equal("", model.Url)
|
||||
suite.Equal("", model.MojangTextures)
|
||||
suite.Equal("", model.MojangSignature)
|
||||
|
||||
return true
|
||||
})).Times(1).Return(nil)
|
||||
},
|
||||
AfterTest: func(suite *apiTestSuite, response *http.Response) {
|
||||
suite.Equal(201, response.StatusCode)
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
suite.Equal("", string(body))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Update exists identity by changing its identityId",
|
||||
Form: bytes.NewBufferString(url.Values{
|
||||
"identityId": {"2"},
|
||||
"username": {"mock_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 2).Return(nil, nil)
|
||||
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("RemoveSkinByUsername", "mock_username").Times(1).Return(nil)
|
||||
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
|
||||
suite.Equal(2, model.UserId)
|
||||
suite.Equal("mock_username", model.Username)
|
||||
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
|
||||
|
||||
return true
|
||||
})).Times(1).Return(nil)
|
||||
},
|
||||
AfterTest: func(suite *apiTestSuite, response *http.Response) {
|
||||
suite.Equal(201, response.StatusCode)
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
suite.Empty(body)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Update exists identity by changing its username",
|
||||
Form: bytes.NewBufferString(url.Values{
|
||||
"identityId": {"1"},
|
||||
"username": {"changed_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Times(1).Return(nil)
|
||||
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
|
||||
suite.Equal(1, model.UserId)
|
||||
suite.Equal("changed_username", model.Username)
|
||||
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
|
||||
|
||||
return true
|
||||
})).Times(1).Return(nil)
|
||||
},
|
||||
AfterTest: func(suite *apiTestSuite, response *http.Response) {
|
||||
suite.Equal(201, response.StatusCode)
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
suite.Empty(body)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Handle an error when loading the data from the repository",
|
||||
Form: bytes.NewBufferString(url.Values{
|
||||
"identityId": {"1"},
|
||||
"username": {"changed_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, errors.New("can't find skin by user id"))
|
||||
},
|
||||
PanicErr: "can't find skin by user id",
|
||||
},
|
||||
{
|
||||
Name: "Handle an error when saving the data into the repository",
|
||||
Form: bytes.NewBufferString(url.Values{
|
||||
"identityId": {"1"},
|
||||
"username": {"mock_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"1"},
|
||||
"isSlim": {"1"},
|
||||
"url": {"http://textures-server.com/skin.png"},
|
||||
}.Encode()),
|
||||
BeforeTest: func(suite *apiTestSuite) {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures"))
|
||||
},
|
||||
PanicErr: "can't save textures",
|
||||
},
|
||||
}
|
||||
|
||||
func (suite *apiTestSuite) TestPostSkin() {
|
||||
for _, testCase := range postSkinTestsCases {
|
||||
suite.RunSubTest(testCase.Name, func() {
|
||||
testCase.BeforeTest(suite)
|
||||
|
||||
req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
if testCase.PanicErr != "" {
|
||||
suite.PanicsWithError(testCase.PanicErr, func() {
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
})
|
||||
} else {
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
testCase.AfterTest(suite, w.Result())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
suite.RunSubTest("Get errors about required fields", func() {
|
||||
req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{
|
||||
"mojangTextures": {"someBase64EncodedString"},
|
||||
"skinUrl": {"https://example.com/skin.png"},
|
||||
"skinModel": {"slim"},
|
||||
"capeUrl": {"https://example.com/cape.png"},
|
||||
"mojangTextures": {"bW9jawo="},
|
||||
"mojangSignature": {"bW9jawo="},
|
||||
}.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
suite.Equal(400, resp.StatusCode)
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
suite.JSONEq(`{
|
||||
t.Equal(http.StatusCreated, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("handle malformed body", func() {
|
||||
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader("invalid;=url?encoded_string"))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
t.Equal(http.StatusBadRequest, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"errors": {
|
||||
"identityId": [
|
||||
"The identityId field is required",
|
||||
"The identityId field must be numeric",
|
||||
"The identityId field must be minimum 1 char"
|
||||
],
|
||||
"skinId": [
|
||||
"The skinId field is required",
|
||||
"The skinId field must be numeric",
|
||||
"The skinId field must be numeric value between 0 and 0"
|
||||
],
|
||||
"username": [
|
||||
"The username field is required"
|
||||
],
|
||||
"uuid": [
|
||||
"The uuid field is required",
|
||||
"The uuid field must contain valid UUID"
|
||||
],
|
||||
"mojangSignature": [
|
||||
"The mojangSignature field is required"
|
||||
"body": [
|
||||
"The body of the request must be a valid url-encoded string"
|
||||
]
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
/**************************************
|
||||
* Delete skin by user id tests cases *
|
||||
**************************************/
|
||||
|
||||
func (suite *apiTestSuite) TestDeleteByUserId() {
|
||||
suite.RunSubTest("Delete skin by its identity id", func() {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
suite.Equal(204, resp.StatusCode)
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
suite.Empty(body)
|
||||
t.Run("receive validation errors", func() {
|
||||
t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(&profiles.ValidationError{
|
||||
Errors: map[string][]string{
|
||||
"mock": {"error1", "error2"},
|
||||
},
|
||||
})
|
||||
|
||||
suite.RunSubTest("Try to remove not exists identity id", func() {
|
||||
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
|
||||
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader(""))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
suite.Equal(404, resp.StatusCode)
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
suite.JSONEq(`[
|
||||
"Cannot find record for the requested identifier"
|
||||
]`, string(body))
|
||||
t.Equal(http.StatusBadRequest, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"errors": {
|
||||
"mock": [
|
||||
"error1",
|
||||
"error2"
|
||||
]
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("receive other error", func() {
|
||||
t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(errors.New("mock error"))
|
||||
|
||||
req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader(""))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
t.Equal(http.StatusInternalServerError, result.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
/***************************************
|
||||
* Delete skin by username tests cases *
|
||||
***************************************/
|
||||
func (t *ApiTestSuite) TestDeleteProfileByUuid() {
|
||||
t.Run("successfully delete", func() {
|
||||
t.ProfilesManager.On("RemoveProfileByUuid", "0f657aa8-bfbe-415d-b700-5750090d3af3").Once().Return(nil)
|
||||
|
||||
func (suite *apiTestSuite) TestDeleteByUsername() {
|
||||
suite.RunSubTest("Delete skin by its identity username", func() {
|
||||
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
|
||||
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
suite.Equal(204, resp.StatusCode)
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
suite.Empty(body)
|
||||
t.Equal(http.StatusNoContent, resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
suite.RunSubTest("Try to remove not exists identity username", func() {
|
||||
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
|
||||
t.Run("error from manager", func() {
|
||||
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything).Return(errors.New("mock error"))
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
suite.App.Handler().ServeHTTP(w, req)
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
suite.Equal(404, resp.StatusCode)
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
suite.JSONEq(`[
|
||||
"Cannot find record for the requested identifier"
|
||||
]`, string(body))
|
||||
t.Equal(http.StatusInternalServerError, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
/*************
|
||||
* Utilities *
|
||||
*************/
|
||||
|
||||
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
|
||||
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
|
||||
|
||||
func loadSkinFile() []byte {
|
||||
result := make([]byte, 92)
|
||||
_, err := base64.StdEncoding.Decode(result, OnePxPng)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result
|
||||
func TestApi(t *testing.T) {
|
||||
suite.Run(t, new(ApiTestSuite))
|
||||
}
|
||||
|
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
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user