Compare commits

..

12 Commits
4.5.0 ... 4.6.0

Author SHA1 Message Date
ErickSkrauch
980c920ceb Prepare 4.6.0 release 2021-03-04 07:03:07 +01:00
ErickSkrauch
32a9fee3e6 Replace /signature-verification-key endpoint with extension-specific ones /signature-verification-key.{der|pem}.
Fix Content-Disposition header
2021-03-03 13:33:56 +01:00
ErickSkrauch
7cf5ae13be Update Changelog [skip ci] 2021-03-03 01:38:47 +01:00
ErickSkrauch
98d280240e Resolves #28. We didn't manage to catch this error during testing, so we're normalizing its handling and hope that this will not happen again 2021-03-03 01:32:38 +01:00
ErickSkrauch
26042037b6 Increase Mojang's API response timeout 2021-03-03 01:23:34 +01:00
ErickSkrauch
1e3307dcbe Fixes CHRLY-S. Don't return an error for an invalid username 2021-03-03 00:24:31 +01:00
ErickSkrauch
d1d2c7ee6e Merge branch 'sign_textures' 2021-02-27 02:40:13 +01:00
ErickSkrauch
2bc9f8eb57 Rename the signature key param.
Rename the signature verification key endpoint.
Update CHANGELOG and README files
2021-02-27 02:37:59 +01:00
ErickSkrauch
6f148a8791 Implemented /profile/{username} endpoint to get complete profile with signed by the current server textures.
Implemented /signing-key endpoint to get public key in der format, used to sign the textures.
Improved logging of errors from http package.
Changed behavior of the /cloaks endpoint
2021-02-26 02:45:45 +01:00
ErickSkrauch
247499df6a Fixes #29. If a previously cached UUID no longer exists, it will be invalidated and re-requested 2021-02-07 21:19:01 +01:00
ErickSkrauch
3bf6872f3e Resolves CHRLY-H. Add debug statement to investigate #28 2021-02-07 16:47:21 +01:00
ErickSkrauch
60774b6b72 Resolves #27. Replace import of the gock with gopkg.in/h2non/gock.v1 2020-08-21 18:12:31 +03:00
25 changed files with 1245 additions and 282 deletions

View File

@@ -6,6 +6,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] - xxxx-xx-xx
## [4.6.0] - 2021-03-04
### Added
- `/profile/{username}` endpoint, which returns a profile and its textures, equivalent of the Mojang's
[UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape).
- `/signature-verification-key.der` and `/signature-verification-key.pem` endpoints, which returns the public key in
`DER` or `PEM` formats for signature verification.
### Fixed
- [#28](https://github.com/elyby/chrly/issues/28): Added handling of corrupted data from the Mojang's username to UUID
cache.
- [#29](https://github.com/elyby/chrly/issues/29): If a previously cached UUID no longer exists,
it will be invalidated and re-requested.
- Use correct status code for error about empty response from Mojang's API.
### Changed
- **BREAKING**: `/cloaks/{username}` and `/textures/{username}` endpoints will no longer return a cape if there are no
textures for the requested username.
- All endpoints are now returns `500` status code when an error occurred during request processing.
- Increased the response timeout for Mojang's API from 3 to 10 seconds.
## [4.5.0] - 2020-05-01
### Added
- [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of
@@ -146,7 +166,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
from the textures link instead.
- `hash` field from `POST /api/skins` endpoint.
[Unreleased]: https://github.com/elyby/chrly/compare/4.5.0...HEAD
[Unreleased]: https://github.com/elyby/chrly/compare/4.6.0...HEAD
[4.6.0]: https://github.com/elyby/chrly/compare/4.5.0...4.6.0
[4.5.0]: https://github.com/elyby/chrly/compare/4.4.1...4.5.0
[4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1
[4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0

18
Gopkg.lock generated
View File

@@ -83,14 +83,6 @@
revision = "c5c6c98bc25355028a63748a498942a6398ccd22"
version = "v1.7.1"
[[projects]]
digest = "1:5eeb4bfc6db411dbb34a6d9e5d49a9956b160d59fd004ee8f03fe53c9605c082"
name = "github.com/h2non/gock"
packages = ["."]
pruneopts = ""
revision = "ba88c4862a27596539531ce469478a91bc5a0511"
version = "v1.0.14"
[[projects]]
digest = "1:0f31ddb2589297fc1d716f45b34e34bff34e968de1aa239543274c87522e86f4"
name = "github.com/h2non/parth"
@@ -308,6 +300,14 @@
pruneopts = ""
revision = "bd91bbf73e9a4a801adbfb97133c992678533126"
[[projects]]
digest = "1:2a7fe9cf6ed8bca3f0ef319592db1621ce244e7ffefba3c2c21ee40d6898a9c8"
name = "gopkg.in/h2non/gock.v1"
packages = ["."]
pruneopts = ""
revision = "3ffff9b1aa8200275a5eb219c5f9c62bd27acb31"
version = "v1.0.15"
[[projects]]
branch = "v2"
digest = "1:81314a486195626940617e43740b4fa073f265b0715c9f54ce2027fee1cb5f61"
@@ -327,7 +327,6 @@
"github.com/getsentry/raven-go",
"github.com/goava/di",
"github.com/gorilla/mux",
"github.com/h2non/gock",
"github.com/mediocregopher/radix.v2/pool",
"github.com/mediocregopher/radix.v2/redis",
"github.com/mediocregopher/radix.v2/util",
@@ -345,6 +344,7 @@
"github.com/stretchr/testify/require",
"github.com/stretchr/testify/suite",
"github.com/thedevsaddam/govalidator",
"gopkg.in/h2non/gock.v1",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -52,5 +52,5 @@ ignored = ["github.com/elyby/chrly"]
version = "^1.3.0"
[[constraint]]
name = "github.com/h2non/gock"
version = "^1.0.6"
name = "gopkg.in/h2non/gock.v1"
version = "^1.0.15"

View File

@@ -15,8 +15,9 @@ production ready.
## Installation
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` environment variable
that you must set before running `docker-compose up -d`. Other possible variables are described below.
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY`
environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
below.
```yml
version: '2'
@@ -33,6 +34,7 @@ services:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
redis:
image: redis:4.0-32bit
@@ -41,6 +43,11 @@ services:
- ./data/redis:/data
```
**Tip**: to generate a value for the `CHRLY_SIGNING_KEY` use the command below and then join it with a `base64:` prefix.
```sh
openssl genrsa 4096 | base64 -w0
```
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations.
@@ -48,11 +55,10 @@ the host machine to do not lose data on container recreations.
Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key
inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated.
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `stop`
and `up` it:
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `up`
it again:
```sh
docker-compose stop app
docker-compose up -d app
```
@@ -182,7 +188,7 @@ If something goes wrong, you can always access logs by executing `docker-compose
## Endpoints
Each endpoint that accepts `username` as a part of an url takes it case insensitive. `.png` part can be omitted too.
Each endpoint that accepts `username` as a part of an url takes it case-insensitive. The `.png` postfix can be omitted.
#### `GET /skins/{username}.png`
@@ -220,11 +226,75 @@ That request is handy in case when your server implements authentication for a g
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
to the Chrly server and put the result in your hasJoined response.
#### `GET /profile/{username}`
This endpoint behaves exactly like the
[Mojang's UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape), but using
a username instead of the UUID. Just like in the Mojang's API, you can append `?unsigned=false` part to URL to sign
the `textures` property. If the textures for the requested username aren't found, it'll request them through the
Mojang's API, but the Mojang's signature will be discarded and the textures will be re-signed using the signature key
for your Chrly instance.
Response example:
```json
{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "username",
"properties": [
{
"name": "textures",
"signature": "signature value",
"value": "base64 encoded value"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}
```
The base64 `value` string for the `textures` property decoded:
```json
{
"timestamp": 1614387238630,
"profileId": "0f657aa8bfbe415db7005750090d3af3",
"profileName": "username",
"textures": {
"SKIN": {
"url": "http://example.com/skin.png"
},
"CAPE": {
"url": "http://example.com/cape.png"
}
}
}
```
If username can't be found locally and can't be obtained from the Mojang's API, empty response with `204` status code
will be sent.
Note that this endpoint will try to use the UUID for the stored profile in the database. This is an edge case, related
to the situation where the user is available in the database but has no textures, which caused them to be retrieved
from the Mojang's API.
#### `GET /signature-verification-key.der`
This endpoint returns a public key that can be used to verify textures signatures. The key is provided in `DER` format,
so it can be used directly in the Authlib, without modifying the signature checking algorithm.
#### `GET /signature-verification-key.pem`
The same endpoint as the previous one, except that it returns the key in `PEM` format.
#### `GET /textures/signed/{username}`
Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if
you have your own source of Mojang's signatures, then you can pass it with textures and it'll be displayed in response
of this endpoint. Received response should be directly sent to the client without any modification via game server API.
Actually, this is the [Ely.by](https://ely.by)'s feature called
[Server Skins System](https://ely.by/server-skins-system), but if you have your own source of Mojang's signatures,
then you can pass it with textures and it'll be displayed in response of this endpoint. Received response should be
directly sent to the client without any modification via game server API.
Response example:

View File

@@ -12,7 +12,7 @@ import (
)
var HttpClient = &http.Client{
Timeout: 3 * time.Second,
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: 1024,
},
@@ -166,7 +166,7 @@ type EmptyResponse struct {
}
func (*EmptyResponse) Error() string {
return "200: Empty Response"
return "204: Empty Response"
}
func (*EmptyResponse) IsMojangError() bool {

View File

@@ -4,7 +4,8 @@ import (
"net/http"
"testing"
"github.com/h2non/gock"
"gopkg.in/h2non/gock.v1"
testify "github.com/stretchr/testify/assert"
)
@@ -260,7 +261,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&EmptyResponse{}, err)
assert.EqualError(err, "200: Empty Response")
assert.EqualError(err, "204: Empty Response")
assert.Implements((*ResponseError)(nil), err)
})

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"compress/zlib"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
@@ -204,6 +205,12 @@ func findMojangUuidByUsername(username string, conn util.Cmder) (string, bool, e
data, _ := response.Str()
parts := strings.Split(data, ":")
// https://github.com/elyby/chrly/issues/28
if len(parts) < 2 {
conn.Cmd("HDEL", mojangUsernameToUuidKey, key)
return "", false, fmt.Errorf("Got unexpected response from the mojangUsernameToUuid hash: \"%s\"", data)
}
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {

View File

@@ -357,20 +357,52 @@ func (suite *redisTestSuite) TestGetUuid() {
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().True(resp.IsType(redis.Nil), "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.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().False(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().True(resp.IsType(redis.Nil), "should cleanup expired records")
})
}
func (suite *redisTestSuite) TestStoreUuid() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
suite.RunSubTest("store uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().False(resp.IsType(redis.Nil))
str, _ := resp.Str()
suite.Require().Equal(str, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().False(resp.IsType(redis.Nil))
str, _ := resp.Str()
suite.Require().Equal(str, "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.StoreUuid("Mock", "")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().False(resp.IsType(redis.Nil))
str, _ := resp.Str()
suite.Require().Equal(str, ":1587435016")
})
}
func (suite *redisTestSuite) TestPing() {

View File

@@ -11,6 +11,7 @@ func New() (*di.Container, error) {
mojangTextures,
handlers,
server,
signer,
)
if err != nil {
return nil, err

View File

@@ -104,6 +104,7 @@ func newSkinsystemHandler(
skinsRepository SkinsRepository,
capesRepository CapesRepository,
mojangTexturesProvider MojangTexturesProvider,
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?")
@@ -113,14 +114,14 @@ func newSkinsystemHandler(
SkinsRepo: skinsRepository,
CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider,
TexturesSigner: texturesSigner,
TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
}).Handler()
}
func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router {
func newApiHandler(skinsRepository SkinsRepository) *mux.Router {
return (&Api{
Emitter: emitter,
SkinsRepo: skinsRepository,
}).Handler()
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/getsentry/raven-go"
@@ -42,13 +43,26 @@ func newServer(params serverParams) *http.Server {
params.Config.SetDefault("server.host", "")
params.Config.SetDefault("server.port", 80)
handler := params.Handler
var handler http.Handler
if params.Sentry != nil {
// raven.Recoverer uses DefaultClient and nothing can be done about it
// To avoid code duplication, if the Sentry service is successfully initiated,
// it will also replace DefaultClient, so raven.Recoverer will work with the instance
// created in the application constructor
handler = raven.Recoverer(handler)
handler = raven.Recoverer(params.Handler)
} else {
// Raven's Recoverer is prints the stacktrace and sets the corresponding status itself.
// But there is no magic and if you don't define a panic handler, Mux will just reset the connection
handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
debug.PrintStack() // TODO: colorize output
request.WriteHeader(http.StatusInternalServerError)
}
}()
params.Handler.ServeHTTP(request, response)
})
}
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))

48
di/signer.go Normal file
View File

@@ -0,0 +1,48 @@
package di
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"github.com/elyby/chrly/http"
. "github.com/elyby/chrly/signer"
"strings"
"github.com/goava/di"
"github.com/spf13/viper"
)
var signer = di.Options(
di.Provide(newTexturesSigner,
di.As(new(http.TexturesSigner)),
),
)
func newTexturesSigner(config *viper.Viper) (*Signer, error) {
keyStr := config.GetString("chrly.signing.key")
if keyStr == "" {
return nil, errors.New("chrly.signing.key must be set in order to sign textures")
}
var keyBytes []byte
if strings.HasPrefix(keyStr, "base64:") {
base64Value := keyStr[7:]
decodedKey, err := base64.URLEncoding.DecodeString(base64Value)
if err != nil {
return nil, err
}
keyBytes = decodedKey
} else {
keyBytes = []byte(keyStr)
}
rawPem, _ := pem.Decode(keyBytes)
key, err := x509.ParsePKCS1PrivateKey(rawPem.Bytes)
if err != nil {
return nil, err
}
return &Signer{Key: key}, nil
}

View File

@@ -43,7 +43,6 @@ func init() {
}
type Api struct {
Emitter
SkinsRepo SkinsRepository
}
@@ -68,9 +67,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
record, err := ctx.findIdentityOrCleanup(identityId, username)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err))
apiServerError(resp)
return
panic(err)
}
if record == nil {
@@ -94,9 +91,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
err = ctx.SkinsRepo.SaveSkin(record)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
panic(err)
}
resp.WriteHeader(http.StatusCreated)
@@ -116,9 +111,7 @@ func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err))
apiServerError(resp)
return
panic(err)
}
if skin == nil {
@@ -128,9 +121,7 @@ func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
panic(err)
}
resp.WriteHeader(http.StatusNoContent)

View File

@@ -28,7 +28,6 @@ type apiTestSuite struct {
App *Api
SkinsRepository *skinsRepositoryMock
Emitter *emitterMock
}
/********************
@@ -37,17 +36,14 @@ type apiTestSuite struct {
func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.Emitter = &emitterMock{}
suite.App = &Api{
SkinsRepo: suite.SkinsRepository,
Emitter: suite.Emitter,
}
}
func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *apiTestSuite) RunSubTest(name string, subTest func()) {
@@ -72,6 +68,7 @@ type postSkinTestCase struct {
Name string
Form io.Reader
BeforeTest func(suite *apiTestSuite)
PanicErr string
AfterTest func(suite *apiTestSuite, response *http.Response)
}
@@ -198,6 +195,22 @@ var postSkinTestsCases = []*postSkinTestCase{
},
{
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"},
@@ -209,43 +222,9 @@ var postSkinTestsCases = []*postSkinTestCase{
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "unable to save record to the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when saving the data into 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) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "error on requesting a skin from the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures"))
},
PanicErr: "can't save textures",
},
}
@@ -258,9 +237,14 @@ func (suite *apiTestSuite) TestPostSkin() {
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
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())
}
})
}

View File

@@ -36,7 +36,7 @@ func StartServer(server *http.Server, logger slf.Logger) {
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String()))
server.Shutdown(context.Background())
_ = server.Shutdown(context.Background())
logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String()))
close(done)
}()
@@ -135,7 +135,3 @@ func apiNotFound(resp http.ResponseWriter, reason string) {
})
_, _ = resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

View File

@@ -1,10 +1,16 @@
package http
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"github.com/elyby/chrly/utils"
"io"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
@@ -12,6 +18,8 @@ import (
"github.com/elyby/chrly/model"
)
var timeNow = time.Now
type SkinsRepository interface {
FindSkinByUsername(username string) (*model.Skin, error)
FindSkinByUserId(id int) (*model.Skin, error)
@@ -28,15 +36,30 @@ type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
}
type TexturesSigner interface {
SignTextures(textures string) (string, error)
GetPublicKey() (*rsa.PublicKey, error)
}
type Skinsystem struct {
Emitter
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
TexturesSigner 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)
@@ -44,40 +67,29 @@ func (ctx *Skinsystem) Handler() *mux.Router {
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks")
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)
// Legacy
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
// Utils
router.HandleFunc("/signature-verification-key.der", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
router.HandleFunc("/signature-verification-key.pem", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
return router
}
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != nil && rec.SkinId != 0 {
http.Redirect(response, request, rec.Url, 301)
return
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNotFound)
return
}
skin := texturesProp.Textures.Skin
if skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, skin.Url, 301)
http.Redirect(response, request, profile.Textures.Skin.Url, 301)
}
func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
@@ -88,39 +100,27 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.skinHandler(response, request)
}
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.CapesRepo.FindCapeByUsername(username)
if err == nil && rec != nil {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil || profile.Textures == nil || (profile.CapeFile == nil && profile.Textures.Cape == nil) {
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, rec.File)
return
_, _ = io.Copy(response, profile.CapeFile)
}
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNotFound)
return
}
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, cape.Url, 301)
}
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
@@ -131,104 +131,231 @@ func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *htt
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.capeHandler(response, request)
}
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var textures *mojang.TexturesResponse
skin, skinErr := ctx.SkinsRepo.FindSkinByUsername(username)
cape, capeErr := ctx.CapesRepo.FindCapeByUsername(username)
if (skinErr == nil && skin != nil && skin.SkinId != 0) || (capeErr == nil && cape != nil) {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin != nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
skinTextures.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
textures.Skin = skinTextures
}
if capeErr == nil && cape != nil {
textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
} else {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesProp, _ := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusNoContent)
return
}
textures = texturesProp.Textures
if textures.Skin == nil && textures.Cape == nil {
response.WriteHeader(http.StatusNoContent)
return
}
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
responseData, _ := json.Marshal(textures)
if profile == nil || profile.Textures == nil || (profile.Textures.Skin == nil && profile.Textures.Cape == nil) {
response.WriteHeader(http.StatusNoContent)
return
}
responseData, _ := json.Marshal(profile.Textures)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != 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,
},
},
}
} else if request.URL.Query().Get("proxy") != "" {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err == nil && mojangTextures != nil {
responseData = mojangTextures
}
profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "")
if err != nil {
panic(err)
}
if responseData == nil {
if profile == nil || profile.MojangTextures == "" {
response.WriteHeader(http.StatusNoContent)
return
}
responseData.Props = append(responseData.Props, &mojang.Property{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
})
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
Name: profile.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: profile.MojangSignature,
Value: profile.MojangTextures,
},
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
},
},
}
responseJson, _ := json.Marshal(responseData)
responseJson, _ := json.Marshal(profileResponse)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesPropContent := &mojang.TexturesProp{
Timestamp: utils.UnixMillisecond(timeNow()),
ProfileID: profile.Id,
ProfileName: profile.Username,
Textures: profile.Textures,
}
texturesPropValueJson, _ := json.Marshal(texturesPropContent)
texturesPropEncodedValue := base64.StdEncoding.EncodeToString(texturesPropValueJson)
texturesProp := &mojang.Property{
Name: "textures",
Value: texturesPropEncodedValue,
}
if request.URL.Query().Get("unsigned") == "false" {
signature, err := ctx.TexturesSigner.SignTextures(texturesProp.Value)
if err != nil {
panic(err)
}
texturesProp.Signature = signature
}
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
Name: profile.Username,
Props: []*mojang.Property{
texturesProp,
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
},
},
}
responseJson, _ := json.Marshal(profileResponse)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWriter, request *http.Request) {
publicKey, err := ctx.TexturesSigner.GetPublicKey()
if err != nil {
panic(err)
}
asn1Bytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
panic(err)
}
if strings.HasSuffix(request.URL.Path, ".pem") {
publicKeyBlock := pem.Block{
Type: "PUBLIC KEY",
Bytes: asn1Bytes,
}
publicKeyPemBytes := pem.EncodeToMemory(&publicKeyBlock)
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.pem\"")
_, _ = response.Write(publicKeyPemBytes)
} else {
response.Header().Set("Content-Type", "application/octet-stream")
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.der\"")
_, _ = response.Write(asn1Bytes)
}
}
// 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{
Id: "",
Username: "",
Textures: &mojang.TexturesResponse{}, // Field must be initialized to avoid "null" after json encoding
CapeFile: nil,
MojangTextures: "",
MojangSignature: "",
}
if skin != nil {
profile.Id = strings.Replace(skin.Uuid, "-", "", -1)
profile.Username = skin.Username
}
if skin != nil && skin.SkinId != 0 {
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 a user,
// than we can ignore an error and return profile without textures
if err != nil && profile.Id != "" {
return profile, nil
}
if err != nil || mojangProfile == 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 {
return nil, nil
}
return profile, nil
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}

View File

@@ -2,11 +2,16 @@ package http
import (
"bytes"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"image"
"image/png"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -89,6 +94,25 @@ func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.Si
return result, args.Error(1)
}
type texturesSignerMock struct {
mock.Mock
}
func (m *texturesSignerMock) SignTextures(textures string) (string, error) {
args := m.Called(textures)
return args.String(0), args.Error(1)
}
func (m *texturesSignerMock) GetPublicKey() (*rsa.PublicKey, error) {
args := m.Called()
var publicKey *rsa.PublicKey
if casted, ok := args.Get(0).(*rsa.PublicKey); ok {
publicKey = casted
}
return publicKey, args.Error(1)
}
type skinsystemTestSuite struct {
suite.Suite
@@ -97,6 +121,7 @@ type skinsystemTestSuite struct {
SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock
TexturesSigner *texturesSignerMock
Emitter *emitterMock
}
@@ -105,15 +130,22 @@ type skinsystemTestSuite struct {
********************/
func (suite *skinsystemTestSuite) SetupTest() {
timeNow = func() time.Time {
CET, _ := time.LoadLocation("CET")
return time.Date(2021, 02, 25, 01, 50, 23, 0, CET)
}
suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.TexturesSigner = &texturesSignerMock{}
suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
TexturesSigner: suite.TexturesSigner,
Emitter: suite.Emitter,
TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue",
@@ -124,6 +156,7 @@ func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.TexturesSigner.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
@@ -144,6 +177,7 @@ func TestSkinsystem(t *testing.T) {
type skinsystemTestCase struct {
Name string
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
@@ -156,6 +190,7 @@ var skinsTestsCases = []*skinsystemTestCase{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
@@ -203,6 +238,13 @@ var skinsTestsCases = []*skinsystemTestCase{
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestSkin() {
@@ -213,14 +255,20 @@ func (suite *skinsystemTestSuite) TestSkin() {
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
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("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder()
@@ -241,14 +289,18 @@ func (suite *skinsystemTestSuite) TestSkinGET() {
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
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("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
@@ -267,6 +319,7 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -279,7 +332,7 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -290,7 +343,7 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no cape texture",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -300,7 +353,7 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -310,13 +363,20 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestCape() {
@@ -327,13 +387,19 @@ func (suite *skinsystemTestSuite) TestCape() {
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
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("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
@@ -357,14 +423,18 @@ func (suite *skinsystemTestSuite) TestCapeGET() {
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
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("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
@@ -417,23 +487,9 @@ var texturesTestsCases = []*skinsystemTestCase{
}`, string(body))
},
},
{
Name: "Username exists and has cape, no skin",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
// There is no case when the user has cape, but has no skin.
// In v5 we will rework textures repositories to be more generic about source of textures,
// but right now it's not possible to return profile entity with a cape only.
{
Name: "Username exists and has both skin and cape",
BeforeTest: func(suite *skinsystemTestSuite) {
@@ -458,7 +514,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -479,7 +534,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -490,7 +544,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available, but there is an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -501,7 +554,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -510,6 +562,13 @@ var texturesTestsCases = []*skinsystemTestCase{
suite.Equal("", string(body))
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestTextures() {
@@ -520,9 +579,14 @@ func (suite *skinsystemTestSuite) TestTextures() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
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())
}
})
}
}
@@ -535,6 +599,7 @@ type signedTexturesTestCase struct {
Name string
AllowProxy bool
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
@@ -544,6 +609,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@@ -586,6 +652,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
@@ -605,12 +672,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "00000000000000000000000000000000",
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ=="
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==",
"signature": "mojang signature"
},
{
"name": "texturesParamName",
@@ -633,6 +701,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
suite.Equal("", string(body))
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestSignedTextures() {
@@ -650,9 +725,434 @@ func (suite *skinsystemTestSuite) TestSignedTextures() {
req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
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())
}
})
}
}
testCase.AfterTest(suite, w.Result())
/***************************
* Get profile tests cases *
***************************/
type profileTestCase struct {
Name string
Signed bool
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var profileTestsCases = []*profileTestCase{
{
Name: "Username exists and has both skin and cape, don't sign",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has both skin and cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
suite.TexturesSigner.On("SignTextures", "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19").Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has skin, no cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has slim skin, no cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmciLCJtZXRhZGF0YSI6eyJtb2RlbCI6InNsaW0ifX19fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists, but has no skin and Mojang profile with textures available",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
skin := createSkinModel("mock_username", false)
skin.SkinId = 0
skin.Url = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists, but has no skin and Mojang textures proxy returned an error",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
skin := createSkinModel("mock_username", false)
skin.SkinId = 0
skin.Url = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("shit happened"))
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile with textures available",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty properties",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username not exists and Mojang textures proxy returned an error",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("mojang textures provider error"))
},
PanicErr: "mojang textures provider error",
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
{
Name: "Receive an error from the TexturesSigner",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("", errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (suite *skinsystemTestSuite) TestProfile() {
for _, testCase := range profileTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
url := "http://chrly/profile/mock_username"
if testCase.Signed {
url += "?unsigned=false"
}
req := httptest.NewRequest("GET", url, nil)
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())
}
})
}
}
/***************************
* Get profile tests cases *
***************************/
type signingKeyTestCase struct {
Name string
KeyFormat string
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var signingKeyTestsCases = []*signingKeyTestCase{
{
Name: "Get public key in DER format",
KeyFormat: "DER",
BeforeTest: func(suite *skinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/octet-stream", response.Header.Get("Content-Type"))
suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.der\"", response.Header.Get("Content-Disposition"))
body, _ := ioutil.ReadAll(response.Body)
suite.Equal([]byte{48, 92, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 3, 75, 0, 48, 72, 2, 65, 0, 214, 212, 165, 80, 153, 144, 194, 169, 126, 246, 25, 211, 197, 183, 150, 233, 157, 1, 166, 49, 44, 25, 230, 80, 57, 115, 28, 20, 7, 220, 58, 88, 121, 254, 86, 8, 237, 246, 76, 53, 58, 125, 226, 9, 231, 192, 52, 148, 12, 176, 130, 214, 120, 195, 8, 182, 116, 97, 206, 207, 253, 97, 2, 247, 2, 3, 1, 0, 1}, body)
},
},
{
Name: "Get public key in PEM format",
KeyFormat: "PEM",
BeforeTest: func(suite *skinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("text/plain; charset=utf-8", response.Header.Get("Content-Type"))
suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.pem\"", response.Header.Get("Content-Disposition"))
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----\n", string(body))
},
},
{
Name: "Error while obtaining public key",
KeyFormat: "DER",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.TexturesSigner.On("GetPublicKey").Return(nil, errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (suite *skinsystemTestSuite) TestSignatureVerificationKey() {
for _, testCase := range signingKeyTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key."+strings.ToLower(testCase.KeyFormat), nil)
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())
}
})
}
}
@@ -699,7 +1199,7 @@ func createCapeModel() *model.Cape {
func createEmptyMojangResponse() *mojang.SignedTexturesResponse {
return &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Id: "292a1db7353d476ca99cab8f57mojang",
Name: "mock_username",
Props: []*mojang.Property{},
}
@@ -708,8 +1208,8 @@ func createEmptyMojangResponse() *mojang.SignedTexturesResponse {
func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(),
ProfileID: "00000000000000000000000000000000",
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).UnixNano() / int64(time.Millisecond),
ProfileID: "292a1db7353d476ca99cab8f57mojang",
ProfileName: "mock_username",
Textures: &mojang.TexturesResponse{},
}
@@ -728,8 +1228,9 @@ func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojan
response := createEmptyMojangResponse()
response.Props = append(response.Props, &mojang.Property{
Name: "textures",
Value: mojang.EncodeTextures(textures),
Name: "textures",
Value: mojang.EncodeTextures(textures),
Signature: "mojang signature",
})
return response

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/utils"
)
type inMemoryItem struct {
@@ -53,7 +54,7 @@ func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.Si
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: unixNanoToUnixMicro(time.Now().UnixNano()),
timestamp: utils.UnixMillisecond(time.Now()),
}
}
@@ -89,9 +90,5 @@ func (s *InMemoryTexturesStorage) gc() {
}
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(time.Now().Add(s.Duration * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
return utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1)))
}

View File

@@ -1,7 +1,6 @@
package mojangtextures
import (
"errors"
"regexp"
"strings"
"sync"
@@ -92,7 +91,7 @@ func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResp
})
if !allowedUsernamesRegex.MatchString(username) {
return nil, errors.New("invalid username")
return nil, nil
}
username = strings.ToLower(username)
@@ -135,7 +134,8 @@ func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
ctx.broadcaster.BroadcastAndRemove(username, result)
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
func (ctx *Provider) getResult(username string, cachedUuid string) *broadcastResult {
uuid := cachedUuid
if uuid == "" {
profile, err := ctx.getUuid(username)
if err != nil {
@@ -156,6 +156,12 @@ func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
textures, err := ctx.getTextures(uuid)
if err != nil {
// Previously cached UUIDs may disappear
// In this case we must invalidate UUID cache for given username
if _, ok := err.(*mojang.EmptyResponse); ok && cachedUuid != "" {
return ctx.getResult(username, "")
}
return &broadcastResult{nil, err}
}

View File

@@ -314,6 +314,42 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
suite.Assert().Nil(err)
}
// https://github.com/elyby/chrly/issues/29
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuidThatHasBeenDisappeared() {
expectedErr := &mojang.EmptyResponse{}
expectedProfile := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
var nilTexturesResponse *mojang.SignedTexturesResponse
expectedResult := &mojang.SignedTexturesResponse{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, expectedErr).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
suite.Storage.On("StoreUuid", "username", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once().Return(nil)
suite.Storage.On("StoreTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, expectedErr)
suite.TexturesProvider.On("GetTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
@@ -355,7 +391,7 @@ func (suite *providerTestSuite) TestGetForTheSameUsernames() {
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
result, err := suite.Provider.GetForUsername("Not allowed")
suite.Assert().Error(err, "invalid username")
suite.Assert().Nil(err)
suite.Assert().Nil(result)
}

View File

@@ -6,7 +6,8 @@ import (
. "net/url"
"testing"
"github.com/h2non/gock"
"gopkg.in/h2non/gock.v1"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)

42
signer/signer.go Normal file
View File

@@ -0,0 +1,42 @@
package signer
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"encoding/base64"
"errors"
)
var randomReader = rand.Reader
type Signer struct {
Key *rsa.PrivateKey
}
func (s *Signer) SignTextures(textures string) (string, error) {
if s.Key == nil {
return "", errors.New("Key is empty")
}
message := []byte(textures)
messageHash := sha1.New()
_, _ = messageHash.Write(message)
messageHashSum := messageHash.Sum(nil)
signature, err := rsa.SignPKCS1v15(randomReader, s.Key, crypto.SHA1, messageHashSum)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func (s *Signer) GetPublicKey() (*rsa.PublicKey, error) {
if s.Key == nil {
return nil, errors.New("Key is empty")
}
return &s.Key.PublicKey, nil
}

64
signer/signer_test.go Normal file
View File

@@ -0,0 +1,64 @@
package signer
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"testing"
assert "github.com/stretchr/testify/require"
)
type ConstantReader struct {
}
func (c *ConstantReader) Read(p []byte) (int, error) {
return 1, nil
}
func TestSigner_SignTextures(t *testing.T) {
randomReader = &ConstantReader{}
t.Run("sign textures", func(t *testing.T) {
rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n"))
key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes)
signer := &Signer{key}
signature, err := signer.SignTextures("eyJ0aW1lc3RhbXAiOjE2MTQzMDcxMzQsInByb2ZpbGVJZCI6ImZmYzhmZGM5NTgyNDUwOWU4YTU3Yzk5Yjk0MGZiOTk2IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9lbHkuYnkvc3RvcmFnZS9za2lucy82OWM2NzQwZDI5OTNlNWQ2ZjZhN2ZjOTI0MjBlZmMyOS5wbmcifX0sImVseSI6dHJ1ZX0")
assert.NoError(t, err)
assert.Equal(t, "IyHCxTP5ITquEXTHcwCtLd08jWWy16JwlQeWg8naxhoAVQecHGRdzHRscuxtdq/446kmeox7h4EfRN2A2ZLL+A==", signature)
})
t.Run("empty key", func(t *testing.T) {
signer := &Signer{}
signature, err := signer.SignTextures("hello world")
assert.Error(t, err, "Key is empty")
assert.Empty(t, signature)
})
}
func TestSigner_GetPublicKey(t *testing.T) {
randomReader = &ConstantReader{}
t.Run("get public key", func(t *testing.T) {
rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n"))
key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes)
signer := &Signer{key}
publicKey, err := signer.GetPublicKey()
assert.NoError(t, err)
assert.IsType(t, &rsa.PublicKey{}, publicKey)
})
t.Run("empty key", func(t *testing.T) {
signer := &Signer{}
publicKey, err := signer.GetPublicKey()
assert.Error(t, err, "Key is empty")
assert.Nil(t, publicKey)
})
}

7
utils/time.go Normal file
View File

@@ -0,0 +1,7 @@
package utils
import "time"
func UnixMillisecond(t time.Time) int64 {
return t.UnixNano() / int64(time.Millisecond)
}

16
utils/time_test.go Normal file
View File

@@ -0,0 +1,16 @@
package utils
import (
"time"
"testing"
assert "github.com/stretchr/testify/require"
)
func TestUnixMillisecond(t *testing.T) {
loc, _ := time.LoadLocation("CET")
d := time.Date(2021, 02, 26, 00, 43, 57, 987654321, loc)
assert.Equal(t, int64(1614296637987), UnixMillisecond(d))
}