Merge pull request #19 from elyby/di

DI and some refactorings
This commit is contained in:
ErickSkrauch 2020-04-20 18:44:09 +03:00 committed by GitHub
commit 440b505306
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1542 additions and 1175 deletions

View File

@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss`
- All incoming requests are now logging to the console in
[Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common).
- Added `/healthcheck` endpoint (at the moment checks are only available for the batch Mojang UUIDs provider).
- Graceful server shutdown.
- Panics in http are now logged in Sentry.
### Fixed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and

13
Gopkg.lock generated
View File

@ -63,6 +63,18 @@
pruneopts = ""
revision = "919484f041ea21e7e27be291cee1d6af7bc98864"
[[projects]]
branch = "master"
digest = "1:8c9f13aac9e92f3754ea591b39ada87b9f89f1e75c4b90ccbd0b1084069c436f"
name = "github.com/goava/di"
packages = [
".",
"internal/reflection",
"internal/stacktrace",
]
pruneopts = ""
revision = "1eb6eb721bf050edff0efbf15c31636def701b4b"
[[projects]]
digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2"
name = "github.com/gorilla/mux"
@ -321,6 +333,7 @@
"github.com/asaskevich/EventBus",
"github.com/etherlabsio/healthcheck",
"github.com/getsentry/raven-go",
"github.com/goava/di",
"github.com/gorilla/mux",
"github.com/h2non/gock",
"github.com/mediocregopher/radix.v2/pool",

View File

@ -45,6 +45,10 @@ ignored = ["github.com/elyby/chrly"]
name = "github.com/etherlabsio/healthcheck"
version = "2.0.3"
[[constraint]]
name = "github.com/goava/di"
branch = "master"
# Testing dependencies
[[constraint]]

View File

@ -122,7 +122,7 @@ docker-compose up -d app
<td><code>true</code></td>
</tr>
<tr>
<td>MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER</td>
<td id="remote-mojang-uuids-provider">MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER</td>
<td>
Specifies the preferred provider of the Mojang's UUIDs. Takes <code>remote</code> value.
In any other case, the local queue will be used.
@ -308,6 +308,55 @@ response will be:
}
```
### Worker mode
The worker mode can be used in cooperation with the [remote server mode](#remote-mojang-uuids-provider)
to exchange Mojang usernames to UUIDs. This mode by itself doesn't solve the problem of
[extremely strict limits](https://github.com/elyby/chrly/issues/10) on the number of requests to the Mojang's API.
But with a proxying load balancer (e.g. HAProxy, Nginx, etc.) it's easy to build a cluster of workers,
which will multiply the bandwidth of the exchanging usernames to its UUIDs.
The instructions for setting up a proxy load balancer are outside the context of this documentation,
but you get the idea ;)
#### `GET /api/worker/mojang-uuid/{username}`
Performs [batch usernames exchange to UUIDs](https://github.com/elyby/chrly/issues/1) and returns the result in the
[same format as it returns from the Mojang's API](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time):
```json
{
"id": "3e3ee6c35afa48abb61e8cd8c42fc0d9",
"name": "ErickSkrauch"
}
```
> **Note**: the results aren't cached.
### Health check
#### `GET /healthcheck`
This endpoint can be used to programmatically check the status of the server.
If all internal checks are successful, the server will return `200` status code with the following body:
```json
{
"status": "OK"
}
```
If any of the checks fails, the server will return `503` status code with the following body:
```json
{
"status": "Service Unavailable",
"errors": {
"mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded"
}
}
```
## Development
First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH`

View File

@ -1,103 +0,0 @@
package bootstrap
import (
"fmt"
"net/url"
"os"
"time"
"github.com/getsentry/raven-go"
"github.com/mono83/slf"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
"github.com/mono83/slf/recievers/statsd"
"github.com/mono83/slf/recievers/writer"
"github.com/mono83/slf/wd"
"github.com/spf13/viper"
"github.com/elyby/chrly/dispatcher"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
"github.com/elyby/chrly/version"
)
func CreateLogger(sentryAddr string) (slf.Logger, error) {
wd.AddReceiver(writer.New(writer.Options{
Marker: false,
TimeFormat: "15:04:05.000",
}))
if sentryAddr != "" {
ravenClient, err := raven.New(sentryAddr)
if err != nil {
return nil, err
}
ravenClient.SetEnvironment("production")
ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver")
programVersion := version.Version()
if programVersion != "" {
raven.SetRelease(programVersion)
}
sentryReceiver, err := sentry.NewReceiverWithCustomRaven(ravenClient, &sentry.Config{
MinLevel: "warn",
})
if err != nil {
return nil, err
}
wd.AddReceiver(sentryReceiver)
}
return wd.New("", "").WithParams(rays.Host), nil
}
func CreateStatsReceiver(statsdAddr string) (slf.StatsReporter, error) {
hostname, _ := os.Hostname()
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
wd.AddReceiver(statsdReceiver)
return wd.New("", "").WithParams(rays.Host), nil
}
func init() {
viper.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
viper.SetDefault("queue.batch_size", 10)
}
func CreateMojangUUIDsProvider(emitter http.Emitter) (mojangtextures.UUIDsProvider, error) {
var uuidsProvider mojangtextures.UUIDsProvider
preferredUuidsProvider := viper.GetString("mojang_textures.uuids_provider.driver")
if preferredUuidsProvider == "remote" {
remoteUrl, err := url.Parse(viper.GetString("mojang_textures.uuids_provider.url"))
if err != nil {
return nil, fmt.Errorf("Unable to parse remote url: %w", err)
}
uuidsProvider = &mojangtextures.RemoteApiUuidsProvider{
Emitter: emitter,
Url: *remoteUrl,
}
} else {
uuidsProvider = &mojangtextures.BatchUuidsProvider{
Emitter: emitter,
IterationDelay: viper.GetDuration("queue.loop_delay"),
IterationSize: viper.GetInt("queue.batch_size"),
}
}
return uuidsProvider, nil
}
func CreateEventDispatcher() dispatcher.EventDispatcher {
return dispatcher.New()
}

View File

@ -2,14 +2,16 @@ package cmd
import (
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
. "github.com/goava/di"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/di"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/version"
)
@ -28,6 +30,32 @@ func Execute() {
}
}
func shouldGetContainer() *Container {
container, err := di.New()
if err != nil {
panic(err)
}
return container
}
func startServer(modules []string) {
container := shouldGetContainer()
var config *viper.Viper
err := container.Resolve(&config)
if err != nil {
log.Fatal(err)
}
config.Set("modules", modules)
err = container.Invoke(http.StartServer)
if err != nil {
log.Fatal(err)
}
}
func init() {
cobra.OnInitialize(initConfig)
}
@ -37,10 +65,3 @@ func initConfig() {
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
}
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
return <-ch
}

View File

@ -1,142 +1,17 @@
package cmd
import (
"fmt"
"log"
"os"
"github.com/mono83/slf/wd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Starts HTTP handler for the skins system",
Run: func(cmd *cobra.Command, args []string) {
dispatcher := bootstrap.CreateEventDispatcher()
// TODO: this is a mess, need to organize this code somehow to make services initialization more compact
logger, err := bootstrap.CreateLogger(viper.GetString("sentry.dsn"))
if err != nil {
log.Fatalf("Cannot initialize logger: %v", err)
}
logger.Info("Logger successfully initialized")
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
statsdAddr := viper.GetString("statsd.addr")
if statsdAddr != "" {
statsdReporter, err := bootstrap.CreateStatsReceiver(statsdAddr)
if err != nil {
logger.Emergency("Invalid statsd configuration :err", wd.ErrParam(err))
os.Exit(1)
}
(&eventsubscribers.StatsReporter{StatsReporter: statsdReporter}).ConfigureWithDispatcher(dispatcher)
}
storageFactory := db.StorageFactory{Config: viper.GetViper()}
logger.Info("Initializing skins repository")
redisFactory := storageFactory.CreateFactory("redis")
skinsRepo, err := redisFactory.CreateSkinsRepository()
if err != nil {
logger.Emergency("Error on creating skins repo: :err", wd.ErrParam(err))
os.Exit(1)
}
logger.Info("Skins repository successfully initialized")
logger.Info("Initializing capes repository")
filesystemFactory := storageFactory.CreateFactory("filesystem")
capesRepo, err := filesystemFactory.CreateCapesRepository()
if err != nil {
logger.Emergency("Error on creating capes repo: :err", wd.ErrParam(err))
os.Exit(1)
}
logger.Info("Capes repository successfully initialized")
var mojangTexturesProvider http.MojangTexturesProvider
if viper.GetBool("mojang_textures.enabled") {
logger.Info("Preparing Mojang's textures queue")
mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository()
if err != nil {
logger.Emergency("Error on creating mojang uuids repo: :err", wd.ErrParam(err))
os.Exit(1)
}
uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(dispatcher)
if err != nil {
logger.Emergency("Unable to create mojang uuids provider: :err", wd.ErrParam(err))
os.Exit(1)
}
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
mojangTexturesProvider = &mojangtextures.Provider{
Emitter: dispatcher,
UUIDsProvider: uuidsProvider,
TexturesProvider: &mojangtextures.MojangApiTexturesProvider{
Emitter: dispatcher,
},
Storage: &mojangtextures.SeparatedStorage{
UuidsStorage: mojangUuidsRepository,
TexturesStorage: texturesStorage,
},
}
logger.Info("Mojang's textures queue is successfully initialized")
} else {
logger.Info("Mojang's textures queue is disabled")
mojangTexturesProvider = &mojangtextures.NilProvider{}
}
address := fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port"))
handler := (&http.Skinsystem{
Emitter: dispatcher,
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
MojangTexturesProvider: mojangTexturesProvider,
Authenticator: &http.JwtAuth{
Key: []byte(viper.GetString("chrly.secret")),
Emitter: dispatcher,
},
TexturesExtraParamName: viper.GetString("textures.extra_param_name"),
TexturesExtraParamValue: viper.GetString("textures.extra_param_value"),
}).CreateHandler()
finishChan := make(chan bool)
go func() {
logger.Info("Starting the app, HTTP on: :addr", wd.StringParam("addr", address))
if err := http.Serve(address, handler); err != nil {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
finishChan <- true
}
}()
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :signal, exiting", wd.StringParam("signal", s.String()))
finishChan <- true
}()
<-finishChan
startServer([]string{"skinsystem", "api"})
},
}
func init() {
RootCmd.AddCommand(serveCmd)
viper.SetDefault("server.host", "")
viper.SetDefault("server.port", 80)
viper.SetDefault("storage.redis.host", "localhost")
viper.SetDefault("storage.redis.port", 6379)
viper.SetDefault("storage.redis.poll", 10)
viper.SetDefault("storage.filesystem.basePath", "data")
viper.SetDefault("storage.filesystem.capesDirName", "capes")
viper.SetDefault("mojang_textures.enabled", true)
}

View File

@ -7,15 +7,20 @@ import (
"github.com/elyby/chrly/http"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var tokenCmd = &cobra.Command{
Use: "token",
Short: "Creates a new token, which allows to interact with Chrly API",
Run: func(cmd *cobra.Command, args []string) {
jwtAuth := &http.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))}
token, err := jwtAuth.NewToken(http.SkinScope)
container := shouldGetContainer()
var auth *http.JwtAuth
err := container.Resolve(&auth)
if err != nil {
log.Fatal(err)
}
token, err := auth.NewToken(http.SkinScope)
if err != nil {
log.Fatalf("Unable to create new token. The error is %v\n", err)
}

View File

@ -1,96 +1,17 @@
package cmd
import (
"fmt"
"log"
"os"
"time"
"github.com/etherlabsio/healthcheck"
"github.com/mono83/slf/wd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
)
var workerCmd = &cobra.Command{
Use: "worker",
Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker",
Run: func(cmd *cobra.Command, args []string) {
dispatcher := bootstrap.CreateEventDispatcher()
// TODO: need to find a way to unify this initialization with the serve command
logger, err := bootstrap.CreateLogger(viper.GetString("sentry.dsn"))
if err != nil {
log.Fatalf("Cannot initialize logger: %v", err)
}
logger.Info("Logger successfully initialized")
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
statsdAddr := viper.GetString("statsd.addr")
if statsdAddr != "" {
statsdReporter, err := bootstrap.CreateStatsReceiver(statsdAddr)
if err != nil {
logger.Emergency("Invalid statsd configuration :err", wd.ErrParam(err))
os.Exit(1)
}
(&eventsubscribers.StatsReporter{StatsReporter: statsdReporter}).ConfigureWithDispatcher(dispatcher)
}
uuidsProvider, err := bootstrap.CreateMojangUUIDsProvider(dispatcher)
if err != nil {
logger.Emergency("Unable to parse remote url :err", wd.ErrParam(err))
os.Exit(1)
}
address := fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port"))
handler := (&http.UUIDsWorker{
Emitter: dispatcher,
UUIDsProvider: uuidsProvider,
}).CreateHandler()
handler.Handle("/healthcheck", healthcheck.Handler(
healthcheck.WithChecker(
"mojang-batch-uuids-provider-response",
eventsubscribers.MojangBatchUuidsProviderResponseChecker(
dispatcher,
viper.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"),
),
),
healthcheck.WithChecker(
"mojang-batch-uuids-provider-queue-length",
eventsubscribers.MojangBatchUuidsProviderQueueLengthChecker(
dispatcher,
viper.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"),
),
),
)).Methods("GET")
finishChan := make(chan bool)
go func() {
logger.Info("Starting the worker, HTTP on: :addr", wd.StringParam("addr", address))
if err := http.Serve(address, handler); err != nil {
logger.Error("Error in main(): :err", wd.ErrParam(err))
finishChan <- true
}
}()
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :code, exiting.", wd.StringParam("code", s.String()))
finishChan <- true
}()
<-finishChan
startServer([]string{"worker"})
},
}
func init() {
RootCmd.AddCommand(workerCmd)
viper.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute)
viper.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50)
}

View File

@ -2,35 +2,11 @@ package db
import (
"github.com/elyby/chrly/http"
"github.com/spf13/viper"
"github.com/elyby/chrly/mojangtextures"
)
type StorageFactory struct {
Config *viper.Viper
}
type RepositoriesCreator interface {
CreateSkinsRepository() (http.SkinsRepository, error)
CreateCapesRepository() (http.CapesRepository, error)
CreateMojangUuidsRepository() (mojangtextures.UuidsStorage, error)
}
func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator {
switch backend {
case "redis":
return &RedisFactory{
Host: factory.Config.GetString("storage.redis.host"),
Port: factory.Config.GetInt("storage.redis.port"),
PoolSize: factory.Config.GetInt("storage.redis.poolSize"),
}
case "filesystem":
return &FilesystemFactory{
BasePath: factory.Config.GetString("storage.filesystem.basePath"),
CapesDirName: factory.Config.GetString("storage.filesystem.capesDirName"),
}
}
return nil
}

View File

@ -49,13 +49,17 @@ type filesStorage struct {
func (repository *filesStorage) FindByUsername(username string) (*model.Cape, error) {
if username == "" {
return nil, &http.CapeNotFoundError{Who: username}
return nil, nil
}
capePath := path.Join(repository.path, strings.ToLower(username)+".png")
file, err := os.Open(capePath)
if err != nil {
return nil, &http.CapeNotFoundError{Who: username}
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &model.Cape{

View File

@ -5,7 +5,6 @@ import (
"compress/zlib"
"encoding/json"
"fmt"
"github.com/elyby/chrly/http"
"io"
"strconv"
"strings"
@ -15,6 +14,7 @@ import (
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix.v2/util"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
@ -147,14 +147,10 @@ func (db *redisDb) StoreUuid(username string, uuid string) error {
}
func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
if username == "" {
return nil, &http.SkinNotFoundError{Who: username}
}
redisKey := buildUsernameKey(username)
response := conn.Cmd("GET", redisKey)
if !response.IsType(redis.Str) {
return nil, &http.SkinNotFoundError{Who: username}
if response.IsType(redis.Nil) {
return nil, nil
}
encodedResult, err := response.Bytes()
@ -180,11 +176,14 @@ func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
response := conn.Cmd("HGET", accountIdToUsernameKey, id)
if !response.IsType(redis.Str) {
return nil, &http.SkinNotFoundError{Who: "unknown"}
if response.IsType(redis.Nil) {
return nil, nil
}
username, _ := response.Str()
username, err := response.Str()
if err != nil {
return nil, err
}
return findByUsername(username, conn)
}
@ -192,9 +191,7 @@ func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
func removeByUserId(id int, conn util.Cmder) error {
record, err := findByUserId(id, conn)
if err != nil {
if _, ok := err.(*http.SkinNotFoundError); !ok {
return err
}
return err
}
conn.Cmd("MULTI")
@ -212,13 +209,13 @@ func removeByUserId(id int, conn util.Cmder) error {
func removeByUsername(username string, conn util.Cmder) error {
record, err := findByUsername(username, conn)
if err != nil {
if _, ok := err.(*http.SkinNotFoundError); ok {
return nil
}
return err
}
if record == nil {
return nil
}
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))

14
di/config.go Normal file
View File

@ -0,0 +1,14 @@
package di
import (
"github.com/goava/di"
"github.com/spf13/viper"
)
var config = di.Options(
di.Provide(newConfig),
)
func newConfig() *viper.Viper {
return viper.GetViper()
}

66
di/db.go Normal file
View File

@ -0,0 +1,66 @@
package di
import (
"github.com/goava/di"
"github.com/spf13/viper"
. "github.com/elyby/chrly/db"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var db = di.Options(
di.Provide(newRedisFactory),
di.Provide(newFSFactory),
di.Provide(newSkinsRepository),
di.Provide(newCapesRepository),
di.Provide(newMojangUUIDsRepository),
di.Provide(newMojangSignedTexturesStorage),
)
func newRedisFactory(config *viper.Viper) *RedisFactory {
config.SetDefault("storage.redis.host", "localhost")
config.SetDefault("storage.redis.port", 6379)
config.SetDefault("storage.redis.poll", 10)
return &RedisFactory{
Host: config.GetString("storage.redis.host"),
Port: config.GetInt("storage.redis.port"),
PoolSize: config.GetInt("storage.redis.poolSize"),
}
}
func newFSFactory(config *viper.Viper) *FilesystemFactory {
config.SetDefault("storage.filesystem.basePath", "data")
config.SetDefault("storage.filesystem.capesDirName", "capes")
return &FilesystemFactory{
BasePath: config.GetString("storage.filesystem.basePath"),
CapesDirName: config.GetString("storage.filesystem.capesDirName"),
}
}
// v4 had the idea that it would be possible to separate backends for storing skins and capes.
// But in v5 the storage will be unified, so this is just temporary constructors before large reworking.
//
// Since there are no options for selecting target backends,
// all constants in this case point to static specific implementations.
func newSkinsRepository(factory *RedisFactory) (http.SkinsRepository, error) {
return factory.CreateSkinsRepository()
}
func newCapesRepository(factory *FilesystemFactory) (http.CapesRepository, error) {
return factory.CreateCapesRepository()
}
func newMojangUUIDsRepository(factory *RedisFactory) (mojangtextures.UuidsStorage, error) {
return factory.CreateMojangUuidsRepository()
}
func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage {
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
return texturesStorage
}

20
di/di.go Normal file
View File

@ -0,0 +1,20 @@
package di
import "github.com/goava/di"
func New() (*di.Container, error) {
container, err := di.New(
config,
dispatcher,
logger,
db,
mojangTextures,
handlers,
server,
)
if err != nil {
return nil, err
}
return container, nil
}

36
di/dispatcher.go Normal file
View File

@ -0,0 +1,36 @@
package di
import (
"github.com/goava/di"
"github.com/mono83/slf"
d "github.com/elyby/chrly/dispatcher"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var dispatcher = di.Options(
di.Provide(newDispatcher,
di.As(new(d.Emitter)),
di.As(new(d.Subscriber)),
di.As(new(http.Emitter)),
di.As(new(mojangtextures.Emitter)),
di.As(new(eventsubscribers.Subscriber)),
),
di.Invoke(enableEventsHandlers),
)
func newDispatcher() d.Dispatcher {
return d.New()
}
func enableEventsHandlers(
dispatcher d.Subscriber,
logger slf.Logger,
statsReporter slf.StatsReporter,
) {
// TODO: use idea from https://github.com/goava/di/issues/10#issuecomment-615869852
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
(&eventsubscribers.StatsReporter{StatsReporter: statsReporter}).ConfigureWithDispatcher(dispatcher)
}

151
di/handlers.go Normal file
View File

@ -0,0 +1,151 @@
package di
import (
"net/http"
"strings"
"github.com/etherlabsio/healthcheck"
"github.com/goava/di"
"github.com/gorilla/mux"
"github.com/spf13/viper"
. "github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var handlers = di.Options(
di.Provide(newHandlerFactory, di.As(new(http.Handler))),
di.Provide(newSkinsystemHandler, di.WithName("skinsystem")),
di.Provide(newApiHandler, di.WithName("api")),
di.Provide(newUUIDsWorkerHandler, di.WithName("worker")),
)
func newHandlerFactory(
container *di.Container,
config *viper.Viper,
emitter Emitter,
) (*mux.Router, error) {
enabledModules := config.GetStringSlice("modules")
// gorilla.mux has no native way to combine multiple routers.
// The hack used later in the code works for prefixes in addresses, but leads to misbehavior
// if you set an empty prefix. Since the main application should be mounted at the root prefix,
// we use it as the base router
var router *mux.Router
if hasValue(enabledModules, "skinsystem") {
if err := container.Resolve(&router, di.Name("skinsystem")); err != nil {
return nil, err
}
} else {
router = mux.NewRouter()
}
router.StrictSlash(true)
requestEventsMiddleware := CreateRequestEventsMiddleware(emitter, "skinsystem")
router.Use(requestEventsMiddleware)
// NotFoundHandler doesn't call for registered middlewares, so we must wrap it manually.
// See https://github.com/gorilla/mux/issues/416#issuecomment-600079279
router.NotFoundHandler = requestEventsMiddleware(http.HandlerFunc(NotFoundHandler))
// Enable the worker module before api to allow gorilla.mux to correctly find the target router
// as it uses the first matching and /api overrides the more accurate /api/worker
if hasValue(enabledModules, "worker") {
var workerRouter *mux.Router
if err := container.Resolve(&workerRouter, di.Name("worker")); err != nil {
return nil, err
}
mount(router, "/api/worker", workerRouter)
}
if hasValue(enabledModules, "api") {
var apiRouter *mux.Router
if err := container.Resolve(&apiRouter, di.Name("api")); err != nil {
return nil, err
}
var authenticator Authenticator
if err := container.Resolve(&authenticator); err != nil {
return nil, err
}
apiRouter.Use(CreateAuthenticationMiddleware(authenticator))
mount(router, "/api", apiRouter)
}
// Resolve health checkers last, because all the services required by the application
// must first be initialized and each of them can publish its own checkers
var healthCheckers []*namedHealthChecker
if container.Has(&healthCheckers) {
if err := container.Resolve(&healthCheckers); err != nil {
return nil, err
}
checkersOptions := make([]healthcheck.Option, len(healthCheckers))
for i, checker := range healthCheckers {
checkersOptions[i] = healthcheck.WithChecker(checker.Name, checker.Checker)
}
router.Handle("/healthcheck", healthcheck.Handler()).Methods("GET")
}
return router, nil
}
func newSkinsystemHandler(
config *viper.Viper,
emitter Emitter,
skinsRepository SkinsRepository,
capesRepository CapesRepository,
mojangTexturesProvider MojangTexturesProvider,
) *mux.Router {
config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
return (&Skinsystem{
Emitter: emitter,
SkinsRepo: skinsRepository,
CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider,
TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
}).Handler()
}
func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router {
return (&Api{
Emitter: emitter,
SkinsRepo: skinsRepository,
}).Handler()
}
func newUUIDsWorkerHandler(mojangUUIDsProvider *mojangtextures.BatchUuidsProvider) *mux.Router {
return (&UUIDsWorker{
MojangUuidsProvider: mojangUUIDsProvider,
}).Handler()
}
func hasValue(slice []string, needle string) bool {
for _, value := range slice {
if value == needle {
return true
}
}
return false
}
func mount(router *mux.Router, path string, handler http.Handler) {
router.PathPrefix(path).Handler(
http.StripPrefix(
strings.TrimSuffix(path, "/"),
handler,
),
)
}
type namedHealthChecker struct {
Name string
Checker healthcheck.Checker
}

97
di/logger.go Normal file
View File

@ -0,0 +1,97 @@
package di
import (
"os"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"github.com/mono83/slf"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
"github.com/mono83/slf/recievers/statsd"
"github.com/mono83/slf/recievers/writer"
"github.com/mono83/slf/wd"
"github.com/spf13/viper"
"github.com/elyby/chrly/version"
)
var logger = di.Options(
di.Provide(newLogger),
di.Provide(newSentry),
di.Provide(newStatsReporter),
)
type loggerParams struct {
di.Inject
SentryRaven *raven.Client `di:"" optional:"true"`
}
func newLogger(params loggerParams) slf.Logger {
dispatcher := &slf.Dispatcher{}
dispatcher.AddReceiver(writer.New(writer.Options{
Marker: false,
TimeFormat: "15:04:05.000",
}))
if params.SentryRaven != nil {
sentryReceiver, _ := sentry.NewReceiverWithCustomRaven(
params.SentryRaven,
&sentry.Config{
MinLevel: "warn",
},
)
dispatcher.AddReceiver(sentryReceiver)
}
logger := wd.Custom("", "", dispatcher)
logger.WithParams(rays.Host)
return logger
}
func newSentry(config *viper.Viper) (*raven.Client, error) {
sentryAddr := config.GetString("sentry.dsn")
if sentryAddr == "" {
return nil, nil
}
ravenClient, err := raven.New(sentryAddr)
if err != nil {
return nil, err
}
ravenClient.SetEnvironment("production")
ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver")
ravenClient.SetRelease(version.Version())
raven.DefaultClient = ravenClient
return ravenClient, nil
}
func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) {
dispatcher := &slf.Dispatcher{}
statsdAddr := config.GetString("statsd.addr")
if statsdAddr == "" {
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
dispatcher.AddReceiver(statsdReceiver)
}
return wd.Custom("", "", dispatcher), nil
}

148
di/mojang_textures.go Normal file
View File

@ -0,0 +1,148 @@
package di
import (
"fmt"
"net/url"
"time"
"github.com/goava/di"
"github.com/spf13/viper"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var mojangTextures = di.Options(
di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangTexturesRemoteUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider),
di.Provide(newMojangTexturesStorageFactory),
)
func newMojangTexturesProviderFactory(
container *di.Container,
config *viper.Viper,
) (http.MojangTexturesProvider, error) {
config.SetDefault("mojang_textures.enabled", true)
if !config.GetBool("mojang_textures.enabled") {
return &mojangtextures.NilProvider{}, nil
}
var provider *mojangtextures.Provider
err := container.Resolve(&provider)
if err != nil {
return nil, err
}
return provider, nil
}
func newMojangTexturesProvider(
emitter mojangtextures.Emitter,
uuidsProvider mojangtextures.UUIDsProvider,
texturesProvider mojangtextures.TexturesProvider,
storage mojangtextures.Storage,
) *mojangtextures.Provider {
return &mojangtextures.Provider{
Emitter: emitter,
UUIDsProvider: uuidsProvider,
TexturesProvider: texturesProvider,
Storage: storage,
}
}
func newMojangTexturesUuidsProviderFactory(
config *viper.Viper,
container *di.Container,
) (mojangtextures.UUIDsProvider, error) {
preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver")
if preferredUuidsProvider == "remote" {
var provider *mojangtextures.RemoteApiUuidsProvider
err := container.Resolve(&provider)
return provider, err
}
var provider *mojangtextures.BatchUuidsProvider
err := container.Resolve(&provider)
return provider, err
}
func newMojangTexturesBatchUUIDsProvider(
container *di.Container,
config *viper.Viper,
emitter mojangtextures.Emitter,
) (*mojangtextures.BatchUuidsProvider, error) {
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute)
return &namedHealthChecker{
Name: "mojang-batch-uuids-provider-response",
Checker: es.MojangBatchUuidsProviderResponseChecker(
emitter,
config.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"),
),
}
}); err != nil {
return nil, err
}
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50)
return &namedHealthChecker{
Name: "mojang-batch-uuids-provider-queue-length",
Checker: es.MojangBatchUuidsProviderQueueLengthChecker(
emitter,
config.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"),
),
}
}); err != nil {
return nil, err
}
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return &mojangtextures.BatchUuidsProvider{
Emitter: emitter,
IterationDelay: config.GetDuration("queue.loop_delay"),
IterationSize: config.GetInt("queue.batch_size"),
}, nil
}
func newMojangTexturesRemoteUUIDsProvider(
config *viper.Viper,
emitter mojangtextures.Emitter,
) (*mojangtextures.RemoteApiUuidsProvider, error) {
remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url"))
if err != nil {
return nil, fmt.Errorf("unable to parse remote url: %w", err)
}
return &mojangtextures.RemoteApiUuidsProvider{
Emitter: emitter,
Url: *remoteUrl,
}, nil
}
func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider {
return &mojangtextures.MojangApiTexturesProvider{
Emitter: emitter,
}
}
func newMojangTexturesStorageFactory(
uuidsStorage mojangtextures.UuidsStorage,
texturesStorage mojangtextures.TexturesStorage,
) mojangtextures.Storage {
return &mojangtextures.SeparatedStorage{
UuidsStorage: uuidsStorage,
TexturesStorage: texturesStorage,
}
}

65
di/server.go Normal file
View File

@ -0,0 +1,65 @@
package di
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"github.com/spf13/viper"
. "github.com/elyby/chrly/http"
)
var server = di.Options(
di.Provide(newAuthenticator, di.As(new(Authenticator))),
di.Provide(newServer),
)
func newAuthenticator(config *viper.Viper, emitter Emitter) (*JwtAuth, error) {
key := config.GetString("chrly.secret")
if key == "" {
return nil, errors.New("chrly.secret must be set in order to use authenticator")
}
return &JwtAuth{
Key: []byte(key),
Emitter: emitter,
}, nil
}
type serverParams struct {
di.Inject
Config *viper.Viper `di:""`
Handler http.Handler `di:""`
Sentry *raven.Client `di:"" optional:"true"`
}
func newServer(params serverParams) *http.Server {
params.Config.SetDefault("server.host", "")
params.Config.SetDefault("server.port", 80)
handler := params.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)
}
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))
server := &http.Server{
Addr: address,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
return server
}

View File

@ -2,25 +2,33 @@ package dispatcher
import "github.com/asaskevich/EventBus"
type EventDispatcher interface {
type Subscriber interface {
Subscribe(topic string, fn interface{})
}
type Emitter interface {
Emit(topic string, args ...interface{})
}
type LocalEventDispatcher struct {
type Dispatcher interface {
Subscriber
Emitter
}
type localEventDispatcher struct {
bus EventBus.Bus
}
func (d *LocalEventDispatcher) Subscribe(topic string, fn interface{}) {
func (d *localEventDispatcher) Subscribe(topic string, fn interface{}) {
_ = d.bus.Subscribe(topic, fn)
}
func (d *LocalEventDispatcher) Emit(topic string, args ...interface{}) {
func (d *localEventDispatcher) Emit(topic string, args ...interface{}) {
d.bus.Publish(topic, args...)
}
func New() EventDispatcher {
return &LocalEventDispatcher{
func New() Dispatcher {
return &localEventDispatcher{
bus: EventBus.New(),
}
}

View File

@ -9,36 +9,36 @@ import (
"github.com/stretchr/testify/assert"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/dispatcher"
)
func TestMojangBatchUuidsProviderChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond)
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
t.Run("when no error occurred", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond)
dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil)
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil)
assert.Nil(t, checker(context.Background()))
})
t.Run("when error occurred", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, time.Millisecond)
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred")
dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("should reset value after passed duration", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderResponseChecker(dispatcher, 20*time.Millisecond)
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred")
dispatcher.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background()))
@ -47,22 +47,22 @@ func TestMojangBatchUuidsProviderChecker(t *testing.T) {
func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10)
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
assert.Nil(t, checker(context.Background()))
})
t.Run("less than allowed limit", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10)
dispatcher.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9)
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9)
assert.Nil(t, checker(context.Background()))
})
t.Run("greater than allowed limit", func(t *testing.T) {
dispatcher := bootstrap.CreateEventDispatcher()
checker := MojangBatchUuidsProviderQueueLengthChecker(dispatcher, 10)
dispatcher.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10)
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10)
checkResult := checker(context.Background())
if assert.Error(t, checkResult) {
assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error())

View File

@ -1,5 +1,7 @@
package eventsubscribers
import "github.com/elyby/chrly/dispatcher"
type Subscriber interface {
Subscribe(topic string, fn interface{})
dispatcher.Subscriber
}

225
http/api.go Normal file
View File

@ -0,0 +1,225 @@
package http
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"github.com/gorilla/mux"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/model"
)
//noinspection GoSnakeCaseUsage
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
var regexUuidAny = regexp.MustCompile(UUID_ANY)
func init() {
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
if message == "" {
message = "Skin uploading is temporary unavailable"
}
return errors.New(message)
})
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
if !regexUuidAny.MatchString(str) {
if message == "" {
message = fmt.Sprintf("The %s field must contain valid UUID", field)
}
return errors.New(message)
}
return nil
})
}
type Api struct {
Emitter
SkinsRepo SkinsRepository
}
func (ctx *Api) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost)
router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete)
router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete)
return router
}
func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
apiBadRequest(resp, validationErrors)
return
}
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
username := req.Form.Get("username")
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
}
if record == nil {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
record.Uuid = req.Form.Get("uuid")
record.SkinId = skinId
record.Is1_8 = is18
record.IsSlim = isSlim
record.Url = req.Form.Get("url")
record.MojangTextures = req.Form.Get("mojangTextures")
record.MojangSignature = req.Form.Get("mojangSignature")
err = ctx.SkinsRepo.Save(record)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
}
resp.WriteHeader(http.StatusCreated)
}
func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := ctx.SkinsRepo.FindByUserId(id)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) {
username := mux.Vars(req)["username"]
skin, err := ctx.SkinsRepo.FindByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
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
}
if skin == nil {
apiNotFound(resp, "Cannot find record for the requested identifier")
return
}
err = ctx.SkinsRepo.RemoveByUserId(skin.UserId)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
}
resp.WriteHeader(http.StatusNoContent)
}
func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) {
record, err := ctx.SkinsRepo.FindByUserId(identityId)
if err != nil {
return nil, err
}
if record != nil {
// The username may have changed in the external database,
// so we need to remove the old association
if record.Username != username {
_ = ctx.SkinsRepo.RemoveByUserId(identityId)
record.Username = username
}
return record, nil
}
// If the requested id was not found, then username was reassigned to another user
// who has not uploaded his data to Chrly yet
record, err = ctx.SkinsRepo.FindByUsername(username)
if err != nil {
return nil, err
}
// If the target username does exist, clear it as it will be reassigned to the new user
if record != nil {
_ = ctx.SkinsRepo.RemoveByUsername(username)
record.UserId = identityId
return record, nil
}
return nil, nil
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
_ = request.ParseMultipartForm(maxMultipartMemory)
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"is1_8": {"bool"},
"isSlim": {"bool"},
}
shouldAppendSkinRequiredError := false
url := request.Form.Get("url")
_, _, skinErr := request.FormFile("skin")
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
shouldAppendSkinRequiredError = true
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = []string{"required"}
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
FormSize: maxMultipartMemory,
})
validationResults := validator.Validate()
if shouldAppendSkinRequiredError {
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
}
if len(validationResults) != 0 {
return validationResults
}
return nil
}

442
http/api_test.go Normal file
View File

@ -0,0 +1,442 @@
package http
import (
"bytes"
"encoding/base64"
"errors"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
)
/***************
* Setup mocks *
***************/
type apiTestSuite struct {
suite.Suite
App *Api
SkinsRepository *skinsRepositoryMock
Emitter *emitterMock
}
/********************
* Setup test suite *
********************/
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()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestApi(t *testing.T) {
suite.Run(t, new(apiTestSuite))
}
/*************************
* Post skin tests cases *
*************************/
type postSkinTestCase struct {
Name string
Form io.Reader
BeforeTest func(suite *apiTestSuite)
AfterTest func(suite *apiTestSuite, response *http.Response)
}
var postSkinTestsCases = []*postSkinTestCase{
{
Name: "Upload new identity with textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(nil, nil)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("http://example.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing only textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.True(model.Is1_8)
suite.True(model.IsSlim)
suite.Equal("http://textures-server.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its identityId",
Form: bytes.NewBufferString(url.Values{
"identityId": {"2"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindByUserId", 2).Return(nil, nil)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUsername", "mock_username").Times(1).Return(nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(2, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its username",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Times(1).Return(nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("changed_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("Save", 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("FindByUserId", 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)
},
},
}
func (suite *apiTestSuite) TestPostSkin() {
for _, testCase := range postSkinTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Get errors about required fields", func() {
req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{
"mojangTextures": {"someBase64EncodedString"},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
"The identityId field must be numeric",
"The identityId field must be minimum 1 char"
],
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
],
"username": [
"The username field is required"
],
"uuid": [
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
}
}`, string(body))
})
suite.RunSubTest("Upload textures with skin as file", func() {
inputBody := &bytes.Buffer{}
writer := multipart.NewWriter(inputBody)
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://chrly/skins", inputBody)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
responseBody, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(responseBody))
})
}
/**************************************
* Delete skin by user id tests cases *
**************************************/
func (suite *apiTestSuite) TestDeleteByUserId() {
suite.RunSubTest("Delete skin by its identity id", func() {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity id", func() {
suite.SkinsRepository.On("FindByUserId", 1).Return(nil, nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
/***************************************
* Delete skin by username tests cases *
***************************************/
func (suite *apiTestSuite) TestDeleteByUsername() {
suite.RunSubTest("Delete skin by its identity username", func() {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity username", func() {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
/*************
* Utilities *
*************/
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
func loadSkinFile() []byte {
result := make([]byte, 92)
_, err := base64.StdEncoding.Decode(result, OnePxPng)
if err != nil {
panic(err)
}
return result
}

View File

@ -1,34 +1,51 @@
package http
import (
"context"
"encoding/json"
"net"
"net/http"
"os"
"os/signal"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/dispatcher"
)
type Emitter interface {
Emit(name string, args ...interface{})
dispatcher.Emitter
}
func Serve(address string, handler http.Handler) error {
listener, err := net.Listen("tcp", address)
if err != nil {
return err
}
func StartServer(server *http.Server, logger slf.Logger) {
done := make(chan bool, 1)
go func() {
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
}
server := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
close(done)
}()
return server.Serve(listener)
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String()))
server.Shutdown(context.Background())
logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String()))
close(done)
}()
<-done
}
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
return <-ch
}
type loggingResponseWriter struct {
@ -78,7 +95,7 @@ func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc {
}
}
func NotFound(response http.ResponseWriter, _ *http.Request) {
func NotFoundHandler(response http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",
"message": "Not Found",

View File

@ -93,13 +93,13 @@ func TestCreateAuthenticationMiddleware(t *testing.T) {
})
}
func TestNotFound(t *testing.T) {
func TestNotFoundHandler(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://example.com", nil)
w := httptest.NewRecorder()
NotFound(w, req)
NotFoundHandler(w, req)
resp := w.Result()
assert.Equal(404, resp.StatusCode)

View File

@ -3,49 +3,16 @@ package http
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
//noinspection GoSnakeCaseUsage
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
var regexUuidAny = regexp.MustCompile(UUID_ANY)
func init() {
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
if message == "" {
message = "Skin uploading is temporary unavailable"
}
return errors.New(message)
})
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
if !regexUuidAny.MatchString(str) {
if message == "" {
message = fmt.Sprintf("The %s field must contain valid UUID", field)
}
return errors.New(message)
}
return nil
})
}
type SkinsRepository interface {
FindByUsername(username string) (*model.Skin, error)
FindByUserId(id int) (*model.Skin, error)
@ -58,67 +25,37 @@ type CapesRepository interface {
FindByUsername(username string) (*model.Cape, error)
}
type SkinNotFoundError struct {
Who string
}
func (e SkinNotFoundError) Error() string {
return "skin data not found"
}
type CapeNotFoundError struct {
Who string
}
func (e CapeNotFoundError) Error() string {
return "cape file not found"
}
type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
}
type Skinsystem struct {
Emitter
TexturesExtraParamName string
TexturesExtraParamValue string
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
Authenticator Authenticator
TexturesExtraParamName string
TexturesExtraParamValue string
}
func (ctx *Skinsystem) CreateHandler() *mux.Router {
requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem")
func (ctx *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.Use(requestEventsMiddleware)
router.HandleFunc("/skins/{username}", ctx.Skin).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.Cape).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.Textures).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.SignedTextures).Methods(http.MethodGet)
router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet)
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)
// Legacy
router.HandleFunc("/skins", ctx.SkinGET).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.CapeGET).Methods(http.MethodGet)
// API
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(CreateAuthenticationMiddleware(ctx.Authenticator))
apiRouter.HandleFunc("/skins", ctx.PostSkin).Methods(http.MethodPost)
apiRouter.HandleFunc("/skins/id:{id:[0-9]+}", ctx.DeleteSkinByUserId).Methods(http.MethodDelete)
apiRouter.HandleFunc("/skins/{username}", ctx.DeleteSkinByUsername).Methods(http.MethodDelete)
// 404
// NotFoundHandler doesn't call for registered middlewares, so we must wrap it manually.
// See https://github.com/gorilla/mux/issues/416#issuecomment-600079279
router.NotFoundHandler = requestEventsMiddleware(http.HandlerFunc(NotFound))
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
return router
}
func (ctx *Skinsystem) Skin(response http.ResponseWriter, request *http.Request) {
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.SkinsRepo.FindByUsername(username)
if err == nil && rec.SkinId != 0 {
if err == nil && rec != nil && rec.SkinId != 0 {
http.Redirect(response, request, rec.Url, 301)
return
}
@ -139,7 +76,7 @@ func (ctx *Skinsystem) Skin(response http.ResponseWriter, request *http.Request)
http.Redirect(response, request, skin.Url, 301)
}
func (ctx *Skinsystem) SkinGET(response http.ResponseWriter, request *http.Request) {
func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
@ -149,13 +86,13 @@ func (ctx *Skinsystem) SkinGET(response http.ResponseWriter, request *http.Reque
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.Skin(response, request)
ctx.skinHandler(response, request)
}
func (ctx *Skinsystem) Cape(response http.ResponseWriter, request *http.Request) {
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.CapesRepo.FindByUsername(username)
if err == nil {
if err == nil && rec != nil {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, rec.File)
return
@ -177,7 +114,7 @@ func (ctx *Skinsystem) Cape(response http.ResponseWriter, request *http.Request)
http.Redirect(response, request, cape.Url, 301)
}
func (ctx *Skinsystem) CapeGET(response http.ResponseWriter, request *http.Request) {
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
@ -187,19 +124,18 @@ func (ctx *Skinsystem) CapeGET(response http.ResponseWriter, request *http.Reque
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.Cape(response, request)
ctx.capeHandler(response, request)
}
func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.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.FindByUsername(username)
_, capeErr := ctx.CapesRepo.FindByUsername(username)
if (skinErr == nil && skin.SkinId != 0) || capeErr == nil {
cape, capeErr := ctx.CapesRepo.FindByUsername(username)
if (skinErr == nil && skin != nil && skin.SkinId != 0) || (capeErr == nil && cape != nil) {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin.SkinId != 0 {
if skinErr == nil && skin != nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
@ -213,7 +149,7 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
textures.Skin = skinTextures
}
if capeErr == nil {
if capeErr == nil && cape != nil {
textures.Cape = &mojang.CapeTexturesResponse{
Url: request.URL.Scheme + "://" + request.Host + "/cloaks/" + username,
}
@ -233,6 +169,7 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
}
textures = texturesProp.Textures
// TODO: return 204 in case when there is no skin and cape on mojang textures
}
responseData, _ := json.Marshal(textures)
@ -240,13 +177,13 @@ func (ctx *Skinsystem) Textures(response http.ResponseWriter, request *http.Requ
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *http.Request) {
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
rec, err := ctx.SkinsRepo.FindByUsername(username)
if err == nil && rec.SkinId != 0 && rec.MojangTextures != "" {
if err == nil && rec != nil && rec.SkinId != 0 && rec.MojangTextures != "" {
responseData = &mojang.SignedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
@ -271,8 +208,8 @@ func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *htt
}
responseData.Props = append(responseData.Props, &mojang.Property{
Name: getStringOrDefault(ctx.TexturesExtraParamName, "chrly"),
Value: getStringOrDefault(ctx.TexturesExtraParamValue, "how do you tame a horse in Minecraft?"),
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
})
responseJson, _ := json.Marshal(responseData)
@ -280,166 +217,6 @@ func (ctx *Skinsystem) SignedTextures(response http.ResponseWriter, request *htt
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) PostSkin(resp http.ResponseWriter, req *http.Request) {
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
apiBadRequest(resp, validationErrors)
return
}
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
username := req.Form.Get("username")
record, err := findIdentity(ctx.SkinsRepo, identityId, username)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err))
apiServerError(resp)
return
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
record.Uuid = req.Form.Get("uuid")
record.SkinId = skinId
record.Is1_8 = is18
record.IsSlim = isSlim
record.Url = req.Form.Get("url")
record.MojangTextures = req.Form.Get("mojangTextures")
record.MojangSignature = req.Form.Get("mojangSignature")
err = ctx.SkinsRepo.Save(record)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
}
resp.WriteHeader(http.StatusCreated)
}
func (ctx *Skinsystem) DeleteSkinByUserId(resp http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := ctx.SkinsRepo.FindByUserId(id)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Skinsystem) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Request) {
username := mux.Vars(req)["username"]
skin, err := ctx.SkinsRepo.FindByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Skinsystem) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
if _, ok := err.(*SkinNotFoundError); ok {
apiNotFound(resp, "Cannot find record for the requested identifier")
} else {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err))
apiServerError(resp)
}
return
}
err = ctx.SkinsRepo.RemoveByUserId(skin.UserId)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
}
resp.WriteHeader(http.StatusNoContent)
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
_ = request.ParseMultipartForm(maxMultipartMemory)
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"is1_8": {"bool"},
"isSlim": {"bool"},
}
shouldAppendSkinRequiredError := false
url := request.Form.Get("url")
_, _, skinErr := request.FormFile("skin")
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
shouldAppendSkinRequiredError = true
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = []string{"required"}
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
FormSize: maxMultipartMemory,
})
validationResults := validator.Validate()
if shouldAppendSkinRequiredError {
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
}
if len(validationResults) != 0 {
return validationResults
}
return nil
}
func findIdentity(repo SkinsRepository, identityId int, username string) (*model.Skin, error) {
var record *model.Skin
record, err := repo.FindByUserId(identityId)
if err != nil {
if _, isSkinNotFound := err.(*SkinNotFoundError); !isSkinNotFound {
return nil, err
}
record, err = repo.FindByUsername(username)
if err == nil {
_ = repo.RemoveByUsername(username)
record.UserId = identityId
} else {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
} else if record.Username != username {
_ = repo.RemoveByUserId(identityId)
record.Username = username
}
return record, nil
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}
func getStringOrDefault(value string, def string) string {
if value != "" {
return value
}
return def
}

View File

@ -2,16 +2,11 @@ package http
import (
"bytes"
"encoding/base64"
"errors"
"image"
"image/png"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@ -102,7 +97,6 @@ type skinsystemTestSuite struct {
SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock
Auth *authCheckerMock
Emitter *emitterMock
}
@ -114,15 +108,15 @@ func (suite *skinsystemTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.Auth = &authCheckerMock{}
suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
Authenticator: suite.Auth,
Emitter: suite.Emitter,
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
Emitter: suite.Emitter,
TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue",
}
}
@ -130,7 +124,6 @@ func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.Auth.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
@ -172,7 +165,7 @@ var skinsTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -183,7 +176,7 @@ var skinsTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -193,7 +186,7 @@ var skinsTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -205,28 +198,24 @@ var skinsTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestSkin() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(301, resp.StatusCode)
@ -237,27 +226,23 @@ func (suite *skinsystemTestSuite) TestSkin() {
func (suite *skinsystemTestSuite) TestSkinGET() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Do not pass name param", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(400, resp.StatusCode)
@ -284,7 +269,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("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -295,7 +280,7 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -305,7 +290,7 @@ 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("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -317,28 +302,24 @@ var capesTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestCape() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(200, resp.StatusCode)
@ -351,27 +332,23 @@ func (suite *skinsystemTestSuite) TestCape() {
func (suite *skinsystemTestSuite) TestCapeGET() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Do not pass name param", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(400, resp.StatusCode)
@ -387,7 +364,7 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username exists and has skin, no cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@ -404,7 +381,7 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username exists and has slim skin, no cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{Who: "mock_username"})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@ -423,7 +400,7 @@ var texturesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists and has cape, no skin",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -460,8 +437,8 @@ var texturesTestsCases = []*skinsystemTestCase{
{
Name: "Username not exists, but Mojang profile available",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -481,8 +458,8 @@ var texturesTestsCases = []*skinsystemTestCase{
{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{})
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, &CapeNotFoundError{})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -494,14 +471,12 @@ var texturesTestsCases = []*skinsystemTestCase{
func (suite *skinsystemTestSuite) TestTextures() {
for _, testCase := range texturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
@ -540,8 +515,8 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
"value": "mocked textures base64"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
@ -551,7 +526,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
Name: "Username not exists",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
@ -578,7 +553,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
Name: "Username not exists, but Mojang profile is available and proxying is enabled",
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -594,8 +569,8 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ=="
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
@ -605,7 +580,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
Name: "Username not exists, Mojang profile is unavailable too and proxying is enabled",
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@ -619,8 +594,6 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
func (suite *skinsystemTestSuite) TestSignedTextures() {
for _, testCase := range signedTexturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
testCase.BeforeTest(suite)
var target string
@ -633,417 +606,13 @@ func (suite *skinsystemTestSuite) TestSignedTextures() {
req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}
/*************************
* Post skin tests cases *
*************************/
type postSkinTestCase struct {
Name string
Form io.Reader
ExpectSuccess bool
BeforeTest func(suite *skinsystemTestSuite)
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var postSkinTestsCases = []*postSkinTestCase{
{
Name: "Upload new identity with textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("http://example.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing only textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.True(model.Is1_8)
suite.True(model.IsSlim)
suite.Equal("http://textures-server.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its identityId",
Form: bytes.NewBufferString(url.Values{
"identityId": {"2"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUserId", 2).Return(nil, &SkinNotFoundError{Who: "unknown"})
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUsername", "mock_username").Times(1).Return(nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(2, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its username",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Times(1).Return(nil)
suite.SkinsRepository.On("Save", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("changed_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("Save", 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 *skinsystemTestSuite, 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 *skinsystemTestSuite) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindByUserId", 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 *skinsystemTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
}
func (suite *skinsystemTestSuite) TestPostSkin() {
for _, testCase := range postSkinTestsCases {
suite.RunSubTest(testCase.Name, func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
testCase.BeforeTest(suite)
req := httptest.NewRequest("POST", "http://chrly/api/skins", testCase.Form)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Get errors about required fields", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(url.Values{
"mojangTextures": {"someBase64EncodedString"},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
"The identityId field must be numeric",
"The identityId field must be minimum 1 char"
],
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
],
"username": [
"The username field is required"
],
"uuid": [
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
}
}`, string(body))
})
suite.RunSubTest("Send request without authorization", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
req := httptest.NewRequest("POST", "http://chrly/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
w := httptest.NewRecorder()
suite.Auth.On("Authenticate", mock.Anything).Return(errors.New("Cannot parse passed JWT token"))
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(403, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"error": "Cannot parse passed JWT token"
}`, string(body))
})
suite.RunSubTest("Upload textures with skin as file", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
inputBody := &bytes.Buffer{}
writer := multipart.NewWriter(inputBody)
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", inputBody)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
responseBody, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(responseBody))
})
}
/**************************************
* Delete skin by user id tests cases *
**************************************/
func (suite *skinsystemTestSuite) TestDeleteByUserId() {
suite.RunSubTest("Delete skin by its identity id", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity id", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUserId", 1).Return(nil, &SkinNotFoundError{Who: "unknown"})
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
/***************************************
* Delete skin by username tests cases *
***************************************/
func (suite *skinsystemTestSuite) TestDeleteByUsername() {
suite.RunSubTest("Delete skin by its identity username", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity username", func() {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, mock.Anything)
suite.Auth.On("Authenticate", mock.Anything).Return(nil)
suite.SkinsRepository.On("FindByUsername", "mock_username").Return(nil, &SkinNotFoundError{Who: "mock_username"})
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
/****************
* Custom tests *
****************/
@ -1118,16 +687,3 @@ func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedText
return response
}
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
func loadSkinFile() []byte {
result := make([]byte, 92)
_, err := base64.StdEncoding.Decode(result, OnePxPng)
if err != nil {
panic(err)
}
return result
}

View File

@ -7,37 +7,26 @@ import (
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/mojangtextures"
)
type UuidsProvider interface {
type MojangUuidsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type UUIDsWorker struct {
Emitter
UUIDsProvider mojangtextures.UUIDsProvider
MojangUuidsProvider
}
func (ctx *UUIDsWorker) CreateHandler() *mux.Router {
requestEventsMiddleware := CreateRequestEventsMiddleware(ctx.Emitter, "skinsystem") // This prefix should be unified
func (ctx *UUIDsWorker) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.Use(requestEventsMiddleware)
router.Handle("/api/worker/mojang-uuid/{username}", http.HandlerFunc(ctx.GetUUID)).Methods("GET")
// 404
// NotFoundHandler doesn't call for registered middlewares, so we must wrap it manually.
// See https://github.com/gorilla/mux/issues/416#issuecomment-600079279
router.NotFoundHandler = requestEventsMiddleware(http.HandlerFunc(NotFound))
router.Handle("/mojang-uuid/{username}", http.HandlerFunc(ctx.getUUIDHandler)).Methods("GET")
return router
}
func (ctx *UUIDsWorker) GetUUID(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
profile, err := ctx.UUIDsProvider.GetUuid(username)
func (ctx *UUIDsWorker) getUUIDHandler(response http.ResponseWriter, request *http.Request) {
username := mux.Vars(request)["username"]
profile, err := ctx.GetUuid(username)
if err != nil {
if _, ok := err.(*mojang.TooManyRequestsError); ok {
response.WriteHeader(http.StatusTooManyRequests)

View File

@ -37,7 +37,6 @@ type uuidsWorkerTestSuite struct {
App *UUIDsWorker
UuidsProvider *uuidsProviderMock
Emitter *emitterMock
}
/********************
@ -46,17 +45,14 @@ type uuidsWorkerTestSuite struct {
func (suite *uuidsWorkerTestSuite) SetupTest() {
suite.UuidsProvider = &uuidsProviderMock{}
suite.Emitter = &emitterMock{}
suite.App = &UUIDsWorker{
UUIDsProvider: suite.UuidsProvider,
Emitter: suite.Emitter,
MojangUuidsProvider: suite.UuidsProvider,
}
}
func (suite *uuidsWorkerTestSuite) TearDownTest() {
suite.UuidsProvider.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) {
@ -87,8 +83,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Success provider response",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 200)
suite.UuidsProvider.On("GetUuid", "mock_username").Return(&mojang.ProfileInfo{
Id: "0fcc38620f1845f3a54e1b523c1bd1c7",
Name: "mock_username",
@ -107,8 +101,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Receive empty response from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 204)
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
@ -120,8 +112,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Receive error from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 500)
err := errors.New("this is an error")
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
},
@ -137,8 +127,6 @@ var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Receive Too Many Requests from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.Emitter.On("Emit", "skinsystem:before_request", mock.Anything)
suite.Emitter.On("Emit", "skinsystem:after_request", mock.Anything, 429)
err := &mojang.TooManyRequestsError{}
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
},
@ -155,10 +143,10 @@ func (suite *uuidsWorkerTestSuite) TestGetUUID() {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/api/worker/mojang-uuid/mock_username", nil)
req := httptest.NewRequest("GET", "http://chrly/mojang-uuid/mock_username", nil)
w := httptest.NewRecorder()
suite.App.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})

View File

@ -7,6 +7,7 @@ import (
"sync"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type broadcastResult struct {
@ -72,7 +73,7 @@ type TexturesProvider interface {
}
type Emitter interface {
Emit(name string, args ...interface{})
dispatcher.Emitter
}
type Provider struct {