mirror of
https://github.com/elyby/chrly.git
synced 2024-12-31 17:30:16 +05:30
#1: Integrate queue to the application
This commit is contained in:
parent
f3690686ec
commit
f7cdab243f
@ -13,9 +13,31 @@ var HttpClient = &http.Client{
|
||||
}
|
||||
|
||||
type SignedTexturesResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Props []*Property `json:"properties"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Props []*Property `json:"properties"`
|
||||
decodedTextures *TexturesProp
|
||||
}
|
||||
|
||||
func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
|
||||
if t.decodedTextures == nil {
|
||||
var texturesProp string
|
||||
for _, prop := range t.Props {
|
||||
if prop.Name == "textures" {
|
||||
texturesProp = prop.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if texturesProp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
decodedTextures, _ := DecodeTextures(texturesProp)
|
||||
t.decodedTextures = decodedTextures
|
||||
}
|
||||
|
||||
return t.decodedTextures
|
||||
}
|
||||
|
||||
type Property struct {
|
||||
|
@ -8,6 +8,33 @@ import (
|
||||
"gopkg.in/h2non/gock.v1"
|
||||
)
|
||||
|
||||
func TestSignedTexturesResponse(t *testing.T) {
|
||||
t.Run("DecodeTextures", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
},
|
||||
},
|
||||
}
|
||||
textures := obj.DecodeTextures()
|
||||
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
|
||||
})
|
||||
|
||||
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{},
|
||||
}
|
||||
textures := obj.DecodeTextures()
|
||||
testify.Nil(t, textures)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsernamesToUuids(t *testing.T) {
|
||||
t.Run("exchange usernames to uuids", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
@ -33,6 +33,7 @@ type JobsQueue struct {
|
||||
}
|
||||
|
||||
func (ctx *JobsQueue) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse {
|
||||
// TODO: convert username to lower case
|
||||
ctx.onFirstCall.Do(func() {
|
||||
ctx.queue.New()
|
||||
ctx.broadcast = newBroadcaster()
|
||||
|
@ -20,6 +20,29 @@ type Storage interface {
|
||||
TexturesStorage
|
||||
}
|
||||
|
||||
// SplittedStorage allows you to use separate storage engines to satisfy
|
||||
// the Storage interface
|
||||
type SplittedStorage struct {
|
||||
UuidsStorage
|
||||
TexturesStorage
|
||||
}
|
||||
|
||||
func (s *SplittedStorage) GetUuid(username string) (string, error) {
|
||||
return s.UuidsStorage.GetUuid(username)
|
||||
}
|
||||
|
||||
func (s *SplittedStorage) StoreUuid(username string, uuid string) {
|
||||
s.UuidsStorage.StoreUuid(username, uuid)
|
||||
}
|
||||
|
||||
func (s *SplittedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
return s.TexturesStorage.GetTextures(uuid)
|
||||
}
|
||||
|
||||
func (s *SplittedStorage) StoreTextures(textures *mojang.SignedTexturesResponse) {
|
||||
s.TexturesStorage.StoreTextures(textures)
|
||||
}
|
||||
|
||||
// This error can be used to indicate, that requested
|
||||
// value doesn't exists in the storage
|
||||
type ValueNotFound struct {
|
||||
|
88
api/mojang/queue/storage_test.go
Normal file
88
api/mojang/queue/storage_test.go
Normal file
@ -0,0 +1,88 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type uuidsStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) {
|
||||
m.Called(username, uuid)
|
||||
}
|
||||
|
||||
type texturesStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *texturesStorageMock) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
args := m.Called(uuid)
|
||||
var result *mojang.SignedTexturesResponse
|
||||
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *texturesStorageMock) StoreTextures(textures *mojang.SignedTexturesResponse) {
|
||||
m.Called(textures)
|
||||
}
|
||||
|
||||
func TestSplittedStorage(t *testing.T) {
|
||||
createMockedStorage := func() (*SplittedStorage, *uuidsStorageMock, *texturesStorageMock) {
|
||||
uuidsStorage := &uuidsStorageMock{}
|
||||
texturesStorage := &texturesStorageMock{}
|
||||
|
||||
return &SplittedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage
|
||||
}
|
||||
|
||||
t.Run("GetUuid", func(t *testing.T) {
|
||||
storage, uuidsMock, _ := createMockedStorage()
|
||||
uuidsMock.On("GetUuid", "username").Once().Return("find me", nil)
|
||||
result, err := storage.GetUuid("username")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "find me", result)
|
||||
uuidsMock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("StoreUuid", func(t *testing.T) {
|
||||
storage, uuidsMock, _ := createMockedStorage()
|
||||
uuidsMock.On("StoreUuid", "username", "result").Once()
|
||||
storage.StoreUuid("username", "result")
|
||||
uuidsMock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("GetTextures", func(t *testing.T) {
|
||||
result := &mojang.SignedTexturesResponse{Id: "mock id"}
|
||||
storage, _, texturesMock := createMockedStorage()
|
||||
texturesMock.On("GetTextures", "uuid").Once().Return(result, nil)
|
||||
returned, err := storage.GetTextures("uuid")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, result, returned)
|
||||
texturesMock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("StoreTextures", func(t *testing.T) {
|
||||
toStore := &mojang.SignedTexturesResponse{Id: "mock id"}
|
||||
storage, _, texturesMock := createMockedStorage()
|
||||
texturesMock.On("StoreTextures", toStore).Once()
|
||||
storage.StoreTextures(toStore)
|
||||
texturesMock.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValueNotFound_Error(t *testing.T) {
|
||||
err := &ValueNotFound{}
|
||||
assert.Equal(t, "value not found in the storage", err.Error())
|
||||
}
|
37
cmd/serve.go
37
cmd/serve.go
@ -4,11 +4,11 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/elyby/chrly/auth"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang/queue"
|
||||
"github.com/elyby/chrly/auth"
|
||||
"github.com/elyby/chrly/bootstrap"
|
||||
"github.com/elyby/chrly/db"
|
||||
"github.com/elyby/chrly/http"
|
||||
@ -27,7 +27,8 @@ var serveCmd = &cobra.Command{
|
||||
storageFactory := db.StorageFactory{Config: viper.GetViper()}
|
||||
|
||||
logger.Info("Initializing skins repository")
|
||||
skinsRepo, err := storageFactory.CreateFactory("redis").CreateSkinsRepository()
|
||||
redisFactory := storageFactory.CreateFactory("redis")
|
||||
skinsRepo, err := redisFactory.CreateSkinsRepository()
|
||||
if err != nil {
|
||||
logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err))
|
||||
return
|
||||
@ -35,19 +36,37 @@ var serveCmd = &cobra.Command{
|
||||
logger.Info("Skins repository successfully initialized")
|
||||
|
||||
logger.Info("Initializing capes repository")
|
||||
capesRepo, err := storageFactory.CreateFactory("filesystem").CreateCapesRepository()
|
||||
filesystemFactory := storageFactory.CreateFactory("filesystem")
|
||||
capesRepo, err := filesystemFactory.CreateCapesRepository()
|
||||
if err != nil {
|
||||
logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err))
|
||||
return
|
||||
}
|
||||
logger.Info("Capes repository successfully initialized")
|
||||
|
||||
logger.Info("Preparing Mojang's textures queue")
|
||||
mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository()
|
||||
if err != nil {
|
||||
logger.Emergency(fmt.Sprintf("Error on creating mojang uuids repo: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
mojangTexturesQueue := &queue.JobsQueue{
|
||||
Logger: logger,
|
||||
Storage: &queue.SplittedStorage{
|
||||
UuidsStorage: mojangUuidsRepository,
|
||||
TexturesStorage: queue.CreateInMemoryTexturesStorage(),
|
||||
},
|
||||
}
|
||||
logger.Info("Mojang's textures queue is successfully initialized")
|
||||
|
||||
cfg := &http.Config{
|
||||
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
|
||||
SkinsRepo: skinsRepo,
|
||||
CapesRepo: capesRepo,
|
||||
Logger: logger,
|
||||
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
|
||||
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
|
||||
SkinsRepo: skinsRepo,
|
||||
CapesRepo: capesRepo,
|
||||
MojangTexturesQueue: mojangTexturesQueue,
|
||||
Logger: logger,
|
||||
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
|
||||
}
|
||||
|
||||
if err := cfg.Run(); err != nil {
|
||||
|
21
http/cape.go
21
http/cape.go
@ -14,13 +14,26 @@ func (cfg *Config) Cape(response http.ResponseWriter, request *http.Request) {
|
||||
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
rec, err := cfg.CapesRepo.FindByUsername(username)
|
||||
if err != nil {
|
||||
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
|
||||
if err == nil {
|
||||
request.Header.Set("Content-Type", "image/png")
|
||||
io.Copy(response, rec.File)
|
||||
return
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "image/png")
|
||||
io.Copy(response, rec.File)
|
||||
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
|
||||
if mojangTextures == nil {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
texturesProp := mojangTextures.DecodeTextures()
|
||||
cape := texturesProp.Textures.Cape
|
||||
if cape == nil {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(response, request, cape.Url, 301)
|
||||
}
|
||||
|
||||
func (cfg *Config) CapeGET(response http.ResponseWriter, request *http.Request) {
|
||||
|
@ -19,10 +19,11 @@ import (
|
||||
type Config struct {
|
||||
ListenSpec string
|
||||
|
||||
SkinsRepo interfaces.SkinsRepository
|
||||
CapesRepo interfaces.CapesRepository
|
||||
Logger wd.Watchdog
|
||||
Auth interfaces.AuthChecker
|
||||
SkinsRepo interfaces.SkinsRepository
|
||||
CapesRepo interfaces.CapesRepository
|
||||
MojangTexturesQueue interfaces.MojangTexturesQueue
|
||||
Logger wd.Watchdog
|
||||
Auth interfaces.AuthChecker
|
||||
}
|
||||
|
||||
func (cfg *Config) Run() error {
|
||||
|
@ -5,34 +5,41 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
func (cfg *Config) SignedTextures(response http.ResponseWriter, request *http.Request) {
|
||||
cfg.Logger.IncCounter("signed_textures.request", 1)
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
|
||||
var responseData *mojang.SignedTexturesResponse
|
||||
|
||||
rec, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
if err != nil || rec.SkinId == 0 || rec.MojangTextures == "" {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
if err == nil && rec.SkinId != 0 && rec.MojangTextures != "" {
|
||||
responseData = &mojang.SignedTexturesResponse{
|
||||
Id: strings.Replace(rec.Uuid, "-", "", -1),
|
||||
Name: rec.Username,
|
||||
Props: []*mojang.Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Signature: rec.MojangSignature,
|
||||
Value: rec.MojangTextures,
|
||||
},
|
||||
{
|
||||
Name: "chrly",
|
||||
Value: "how do you tame a horse in Minecraft?",
|
||||
},
|
||||
},
|
||||
}
|
||||
} else if request.URL.Query().Get("proxy") != "" {
|
||||
responseData = <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
|
||||
}
|
||||
|
||||
responseData := mojang.SignedTexturesResponse{
|
||||
Id: strings.Replace(rec.Uuid, "-", "", -1),
|
||||
Name: rec.Username,
|
||||
Props: []*mojang.Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Signature: rec.MojangSignature,
|
||||
Value: rec.MojangTextures,
|
||||
},
|
||||
{
|
||||
Name: "chrly",
|
||||
Value: "how do you tame a horse in Minecraft?",
|
||||
},
|
||||
},
|
||||
if responseData == nil {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
responseJson, _ := json.Marshal(responseData)
|
||||
|
19
http/skin.go
19
http/skin.go
@ -13,12 +13,25 @@ func (cfg *Config) Skin(response http.ResponseWriter, request *http.Request) {
|
||||
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
rec, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
if err != nil || rec.SkinId == 0 {
|
||||
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301)
|
||||
if err == nil && rec.SkinId != 0 {
|
||||
http.Redirect(response, request, rec.Url, 301)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(response, request, rec.Url, 301)
|
||||
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
|
||||
if mojangTextures == nil {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
texturesProp := mojangTextures.DecodeTextures()
|
||||
skin := texturesProp.Textures.Skin
|
||||
if skin == nil {
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(response, request, skin.Url, 301)
|
||||
}
|
||||
|
||||
func (cfg *Config) SkinGET(response http.ResponseWriter, request *http.Request) {
|
||||
|
114
http/textures.go
114
http/textures.go
@ -1,102 +1,64 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/elyby/chrly/model"
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
type texturesResponse struct {
|
||||
Skin *Skin `json:"SKIN"`
|
||||
Cape *Cape `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type Skin struct {
|
||||
Url string `json:"url"`
|
||||
Hash string `json:"hash"`
|
||||
Metadata *skinMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type skinMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type Cape struct {
|
||||
Url string `json:"url"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Textures(response http.ResponseWriter, request *http.Request) {
|
||||
cfg.Logger.IncCounter("textures.request", 1)
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
|
||||
var textures *mojang.TexturesResponse
|
||||
skin, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
if err != nil || skin.SkinId == 0 {
|
||||
if skin == nil {
|
||||
skin = &model.Skin{}
|
||||
if err == nil && skin.SkinId != 0 {
|
||||
textures = &mojang.TexturesResponse{
|
||||
Skin: &mojang.SkinTexturesResponse{
|
||||
Url: skin.Url,
|
||||
},
|
||||
}
|
||||
|
||||
skin.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png"
|
||||
skin.Hash = string(buildNonElyTexturesHash(username))
|
||||
}
|
||||
|
||||
textures := texturesResponse{
|
||||
Skin: &Skin{
|
||||
Url: skin.Url,
|
||||
Hash: skin.Hash,
|
||||
},
|
||||
}
|
||||
|
||||
if skin.IsSlim {
|
||||
textures.Skin.Metadata = &skinMetadata{
|
||||
Model: "slim",
|
||||
}
|
||||
}
|
||||
|
||||
cape, err := cfg.CapesRepo.FindByUsername(username)
|
||||
if err == nil {
|
||||
var scheme string = "http://"
|
||||
if request.TLS != nil {
|
||||
scheme = "https://"
|
||||
if skin.IsSlim {
|
||||
textures.Skin.Metadata = &mojang.SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
}
|
||||
}
|
||||
|
||||
textures.Cape = &Cape{
|
||||
Url: scheme + request.Host + "/cloaks/" + username,
|
||||
Hash: calculateCapeHash(cape),
|
||||
_, err = cfg.CapesRepo.FindByUsername(username)
|
||||
if err == nil {
|
||||
var scheme = "http://"
|
||||
if request.TLS != nil {
|
||||
scheme = "https://"
|
||||
}
|
||||
|
||||
textures.Cape = &mojang.CapeTexturesResponse{
|
||||
Url: scheme + request.Host + "/cloaks/" + username,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
|
||||
if mojangTextures == nil {
|
||||
// TODO: test compatibility with exists authlibs
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
texturesProp := mojangTextures.DecodeTextures()
|
||||
if texturesProp == nil {
|
||||
// TODO: test compatibility with exists authlibs
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
cfg.Logger.Error("Unable to find textures property")
|
||||
return
|
||||
}
|
||||
|
||||
textures = texturesProp.Textures
|
||||
}
|
||||
|
||||
responseData, _ := json.Marshal(textures)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Write(responseData)
|
||||
}
|
||||
|
||||
func calculateCapeHash(cape *model.Cape) string {
|
||||
hasher := md5.New()
|
||||
io.Copy(hasher, cape.File)
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func buildNonElyTexturesHash(username string) string {
|
||||
hour := getCurrentHour()
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte("non-ely-" + strconv.FormatInt(hour, 10) + "-" + username))
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
var timeNow = time.Now
|
||||
|
||||
func getCurrentHour() int64 {
|
||||
n := timeNow()
|
||||
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), 0, 0, 0, time.UTC).Unix()
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
"github.com/elyby/chrly/model"
|
||||
)
|
||||
|
||||
@ -15,3 +16,7 @@ type SkinsRepository interface {
|
||||
type CapesRepository interface {
|
||||
FindByUsername(username string) (*model.Cape, error)
|
||||
}
|
||||
|
||||
type MojangTexturesQueue interface {
|
||||
GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user