mirror of
				https://github.com/elyby/chrly.git
				synced 2025-05-31 14:11:51 +05:30 
			
		
		
		
	Merge branch 'sign_textures'
This commit is contained in:
		
							
								
								
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -5,11 +5,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), | ||||
| and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). | ||||
|  | ||||
| ## [Unreleased] - xxxx-xx-xx | ||||
| ### 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` endpoint, which returns the public key in `DER` format for signature verification. | ||||
|  | ||||
| ### Fixed | ||||
| - [#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. | ||||
|  | ||||
| ## [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 | ||||
|   | ||||
							
								
								
									
										84
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										84
									
								
								README.md
									
									
									
									
									
								
							| @@ -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,71 @@ 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` | ||||
|  | ||||
| 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 /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: | ||||
|  | ||||
|   | ||||
							
								
								
									
										1
									
								
								di/di.go
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								di/di.go
									
									
									
									
									
								
							| @@ -11,6 +11,7 @@ func New() (*di.Container, error) { | ||||
| 		mojangTextures, | ||||
| 		handlers, | ||||
| 		server, | ||||
| 		signer, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
|   | ||||
| @@ -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() | ||||
| } | ||||
|   | ||||
							
								
								
									
										18
									
								
								di/server.go
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								di/server.go
									
									
									
									
									
								
							| @@ -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
									
								
							
							
						
						
									
										48
									
								
								di/signer.go
									
									
									
									
									
										Normal 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 | ||||
| } | ||||
							
								
								
									
										17
									
								
								http/api.go
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								http/api.go
									
									
									
									
									
								
							| @@ -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) | ||||
|   | ||||
| @@ -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()) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -1,10 +1,15 @@ | ||||
| package http | ||||
|  | ||||
| import ( | ||||
| 	"crypto/rsa" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/json" | ||||
| 	"github.com/elyby/chrly/utils" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/gorilla/mux" | ||||
|  | ||||
| @@ -12,6 +17,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 +35,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 +66,28 @@ 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", 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 +98,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 +129,219 @@ 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) | ||||
| 	} | ||||
|  | ||||
| 	_, _ = response.Write(asn1Bytes) | ||||
| 	response.Header().Set("Content-Type", "application/octet-stream") | ||||
| 	response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.der\"") | ||||
| } | ||||
|  | ||||
| // 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") | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,10 @@ package http | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"crypto/rsa" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/pem" | ||||
| 	"errors" | ||||
| 	"image" | ||||
| 	"image/png" | ||||
| 	"io/ioutil" | ||||
| @@ -89,6 +93,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 +120,7 @@ type skinsystemTestSuite struct { | ||||
| 	SkinsRepository        *skinsRepositoryMock | ||||
| 	CapesRepository        *capesRepositoryMock | ||||
| 	MojangTexturesProvider *mojangTexturesProviderMock | ||||
| 	TexturesSigner         *texturesSignerMock | ||||
| 	Emitter                *emitterMock | ||||
| } | ||||
|  | ||||
| @@ -105,15 +129,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 +155,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 +176,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 +189,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 +237,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 +254,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 +288,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 +318,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 +331,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 +342,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 +352,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 +362,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 +386,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 +422,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 +486,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 +513,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 +533,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 +543,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 +553,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 +561,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 +578,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 +598,7 @@ type signedTexturesTestCase struct { | ||||
| 	Name       string | ||||
| 	AllowProxy bool | ||||
| 	BeforeTest func(suite *skinsystemTestSuite) | ||||
| 	PanicErr   string | ||||
| 	AfterTest  func(suite *skinsystemTestSuite, response *http.Response) | ||||
| } | ||||
|  | ||||
| @@ -544,6 +608,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 +651,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 +671,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 +700,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 +724,406 @@ 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 * | ||||
|  ***************************/ | ||||
|  | ||||
| var signingKeyTestsCases = []*skinsystemTestCase{ | ||||
| 	{ | ||||
| 		Name: "Get public key", | ||||
| 		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")) | ||||
| 			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: "Error while obtaining public key", | ||||
| 		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", 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 +1170,7 @@ func createCapeModel() *model.Cape { | ||||
|  | ||||
| func createEmptyMojangResponse() *mojang.SignedTexturesResponse { | ||||
| 	return &mojang.SignedTexturesResponse{ | ||||
| 		Id:    "00000000000000000000000000000000", | ||||
| 		Id:    "292a1db7353d476ca99cab8f57mojang", | ||||
| 		Name:  "mock_username", | ||||
| 		Props: []*mojang.Property{}, | ||||
| 	} | ||||
| @@ -708,8 +1179,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 +1199,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 | ||||
|   | ||||
| @@ -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))) | ||||
| } | ||||
|   | ||||
							
								
								
									
										42
									
								
								signer/signer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								signer/signer.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										64
									
								
								signer/signer_test.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										7
									
								
								utils/time.go
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										16
									
								
								utils/time_test.go
									
									
									
									
									
										Normal 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)) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user