├── .dockerignore ├── data ├── capes │ └── .gitignore └── redis │ └── .gitignore ├── .gitignore ├── model ├── cape.go └── skin.go ├── utils ├── time.go └── time_test.go ├── eventsubscribers ├── subscriber.go ├── logger.go ├── health_checkers.go ├── health_checkers_test.go ├── stats_reporter.go └── logger_test.go ├── main.go ├── version └── version.go ├── di ├── config.go ├── di.go ├── dispatcher.go ├── signer.go ├── db.go ├── server.go ├── logger.go ├── handlers.go └── mojang_textures.go ├── docker-entrypoint.sh ├── mojangtextures ├── nil_mojang_textures.go ├── nil_mojang_textures_test.go ├── mojang_api_textures_provider.go ├── remote_api_uuids_provider.go ├── storage.go ├── in_memory_textures_storage.go ├── storage_test.go ├── mojang_api_textures_provider_test.go ├── remote_api_uuids_provider_test.go ├── in_memory_textures_storage_test.go ├── mojang_textures.go └── batch_uuids_provider.go ├── cmd ├── serve.go ├── worker.go ├── token.go ├── version.go ├── root_profiling.go └── root.go ├── docker-compose.dev.yml ├── db ├── fs │ ├── fs.go │ └── fs_integration_test.go └── redis │ └── redis.go ├── Dockerfile ├── dispatcher └── dispatcher.go ├── signer ├── signer.go └── signer_test.go ├── docker-compose.prod.yml ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── api └── mojang │ ├── textures.go │ ├── textures_test.go │ ├── mojang.go │ └── mojang_test.go ├── http ├── uuids_worker.go ├── jwt.go ├── http_test.go ├── http.go ├── jwt_test.go ├── uuids_worker_test.go ├── api.go └── skinsystem.go ├── go.mod ├── go.sum ├── CHANGELOG.md └── LICENSE /.dockerignore: -------------------------------------------------------------------------------- 1 | data 2 | vendor 3 | -------------------------------------------------------------------------------- /data/capes/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /data/redis/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | docker-compose.yml 3 | docker-compose.override.yml 4 | vendor 5 | .cover 6 | -------------------------------------------------------------------------------- /model/cape.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type Cape struct { 8 | File io.Reader 9 | } 10 | -------------------------------------------------------------------------------- /utils/time.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "time" 4 | 5 | func UnixMillisecond(t time.Time) int64 { 6 | return t.UnixNano() / int64(time.Millisecond) 7 | } 8 | -------------------------------------------------------------------------------- /eventsubscribers/subscriber.go: -------------------------------------------------------------------------------- 1 | package eventsubscribers 2 | 3 | import "github.com/elyby/chrly/dispatcher" 4 | 5 | type Subscriber interface { 6 | dispatcher.Subscriber 7 | } 8 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/elyby/chrly/cmd" 7 | ) 8 | 9 | func main() { 10 | runtime.GOMAXPROCS(runtime.NumCPU()) 11 | cmd.Execute() 12 | } 13 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | version = "" 5 | commit = "" 6 | ) 7 | 8 | func Version() string { 9 | return version 10 | } 11 | 12 | func Commit() string { 13 | return commit 14 | } 15 | -------------------------------------------------------------------------------- /di/config.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "github.com/defval/di" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | var config = di.Options( 9 | di.Provide(newConfig), 10 | ) 11 | 12 | func newConfig() *viper.Viper { 13 | return viper.GetViper() 14 | } 15 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ ! -d /data/capes ]; then 5 | mkdir -p /data/capes 6 | fi 7 | 8 | if [ "$1" = "serve" ] || [ "$1" = "worker" ] || [ "$1" = "token" ] || [ "$1" = "version" ]; then 9 | set -- /usr/local/bin/chrly "$@" 10 | fi 11 | 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /mojangtextures/nil_mojang_textures.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "github.com/elyby/chrly/api/mojang" 5 | ) 6 | 7 | type NilProvider struct { 8 | } 9 | 10 | func (p *NilProvider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) { 11 | return nil, nil 12 | } 13 | -------------------------------------------------------------------------------- /di/di.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import "github.com/defval/di" 4 | 5 | func New() (*di.Container, error) { 6 | container, err := di.New( 7 | config, 8 | dispatcher, 9 | logger, 10 | db, 11 | mojangTextures, 12 | handlers, 13 | server, 14 | signer, 15 | ) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return container, nil 21 | } 22 | -------------------------------------------------------------------------------- /mojangtextures/nil_mojang_textures_test.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNilProvider_GetForUsername(t *testing.T) { 10 | provider := &NilProvider{} 11 | result, err := provider.GetForUsername("username") 12 | assert.Nil(t, result) 13 | assert.Nil(t, err) 14 | } 15 | -------------------------------------------------------------------------------- /utils/time_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "time" 5 | 6 | "testing" 7 | 8 | assert "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestUnixMillisecond(t *testing.T) { 12 | loc, _ := time.LoadLocation("CET") 13 | d := time.Date(2021, 02, 26, 00, 43, 57, 987654321, loc) 14 | 15 | assert.Equal(t, int64(1614296637987), UnixMillisecond(d)) 16 | } 17 | -------------------------------------------------------------------------------- /cmd/serve.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var serveCmd = &cobra.Command{ 8 | Use: "serve", 9 | Short: "Starts HTTP handler for the skins system", 10 | Run: func(cmd *cobra.Command, args []string) { 11 | startServer([]string{"skinsystem", "api"}) 12 | }, 13 | } 14 | 15 | func init() { 16 | RootCmd.AddCommand(serveCmd) 17 | } 18 | -------------------------------------------------------------------------------- /cmd/worker.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var workerCmd = &cobra.Command{ 8 | Use: "worker", 9 | Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker", 10 | Run: func(cmd *cobra.Command, args []string) { 11 | startServer([]string{"worker"}) 12 | }, 13 | } 14 | 15 | func init() { 16 | RootCmd.AddCommand(workerCmd) 17 | } 18 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # This file can be used to start up necessary services. 2 | # Copy it into the docker-compose.yml: 3 | # > cp docker-compose.dev.yml docker-compose.yml 4 | # And then run it: 5 | # > docker-compose up -d 6 | 7 | version: '2' 8 | services: 9 | redis: 10 | image: redis:4.0-32bit 11 | ports: 12 | - "6379:6379" 13 | volumes: 14 | - ./data/redis:/data 15 | -------------------------------------------------------------------------------- /model/skin.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Skin struct { 4 | UserId int `json:"userId"` 5 | Uuid string `json:"uuid"` 6 | Username string `json:"username"` 7 | SkinId int `json:"skinId"` // deprecated 8 | Url string `json:"url"` 9 | Is1_8 bool `json:"is1_8"` 10 | IsSlim bool `json:"isSlim"` 11 | MojangTextures string `json:"mojangTextures"` 12 | MojangSignature string `json:"mojangSignature"` 13 | OldUsername string 14 | } 15 | -------------------------------------------------------------------------------- /mojangtextures/mojang_api_textures_provider.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "github.com/elyby/chrly/api/mojang" 5 | ) 6 | 7 | var uuidToTextures = mojang.UuidToTextures 8 | 9 | type MojangApiTexturesProvider struct { 10 | Emitter 11 | } 12 | 13 | func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { 14 | ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid) 15 | result, err := uuidToTextures(uuid, true) 16 | ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", uuid, result, err) 17 | 18 | return result, err 19 | } 20 | -------------------------------------------------------------------------------- /db/fs/fs.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "strings" 7 | 8 | "github.com/elyby/chrly/model" 9 | ) 10 | 11 | func New(basePath string) (*Filesystem, error) { 12 | return &Filesystem{path: basePath}, nil 13 | } 14 | 15 | type Filesystem struct { 16 | path string 17 | } 18 | 19 | func (f *Filesystem) FindCapeByUsername(username string) (*model.Cape, error) { 20 | capePath := path.Join(f.path, strings.ToLower(username)+".png") 21 | file, err := os.Open(capePath) 22 | if err != nil { 23 | if os.IsNotExist(err) { 24 | return nil, nil 25 | } 26 | 27 | return nil, err 28 | } 29 | 30 | return &model.Cape{ 31 | File: file, 32 | }, nil 33 | } 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM golang:1.21-alpine AS builder 4 | 5 | ARG VERSION=unversioned 6 | ARG COMMIT=unspecified 7 | 8 | COPY . /build 9 | WORKDIR /build 10 | RUN go mod download 11 | 12 | RUN CGO_ENABLED=0 \ 13 | go build \ 14 | -trimpath \ 15 | -ldflags "-w -s -X github.com/elyby/chrly/version.version=$VERSION -X github.com/elyby/chrly/version.commit=$COMMIT" \ 16 | -o chrly \ 17 | main.go 18 | 19 | FROM alpine:3.19 20 | 21 | EXPOSE 80 22 | ENV STORAGE_REDIS_HOST=redis 23 | ENV STORAGE_FILESYSTEM_HOST=/data 24 | 25 | COPY docker-entrypoint.sh / 26 | COPY --from=builder /build/chrly /usr/local/bin/chrly 27 | 28 | ENTRYPOINT ["/docker-entrypoint.sh"] 29 | CMD ["serve"] 30 | -------------------------------------------------------------------------------- /cmd/token.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/elyby/chrly/http" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var tokenCmd = &cobra.Command{ 13 | Use: "token", 14 | Short: "Creates a new token, which allows to interact with Chrly API", 15 | Run: func(cmd *cobra.Command, args []string) { 16 | container := shouldGetContainer() 17 | var auth *http.JwtAuth 18 | err := container.Resolve(&auth) 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | 23 | token, err := auth.NewToken(http.SkinScope) 24 | if err != nil { 25 | log.Fatalf("Unable to create new token. The error is %v\n", err) 26 | } 27 | 28 | fmt.Printf("%s\n", token) 29 | }, 30 | } 31 | 32 | func init() { 33 | RootCmd.AddCommand(tokenCmd) 34 | } 35 | -------------------------------------------------------------------------------- /dispatcher/dispatcher.go: -------------------------------------------------------------------------------- 1 | package dispatcher 2 | 3 | import "github.com/asaskevich/EventBus" 4 | 5 | type Subscriber interface { 6 | Subscribe(topic string, fn interface{}) 7 | } 8 | 9 | type Emitter interface { 10 | Emit(topic string, args ...interface{}) 11 | } 12 | 13 | type Dispatcher interface { 14 | Subscriber 15 | Emitter 16 | } 17 | 18 | type localEventDispatcher struct { 19 | bus EventBus.Bus 20 | } 21 | 22 | func (d *localEventDispatcher) Subscribe(topic string, fn interface{}) { 23 | _ = d.bus.Subscribe(topic, fn) 24 | } 25 | 26 | func (d *localEventDispatcher) Emit(topic string, args ...interface{}) { 27 | d.bus.Publish(topic, args...) 28 | } 29 | 30 | func New() Dispatcher { 31 | return &localEventDispatcher{ 32 | bus: EventBus.New(), 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | "github.com/elyby/chrly/version" 11 | ) 12 | 13 | var versionCmd = &cobra.Command{ 14 | Use: "version", 15 | Short: "Show the Chrly version information", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | hostname, err := os.Hostname() 18 | if err != nil { 19 | hostname = "" 20 | } 21 | 22 | fmt.Printf("Version: %s\n", version.Version()) 23 | fmt.Printf("Commit: %s\n", version.Commit()) 24 | fmt.Printf("Go version: %s\n", runtime.Version()) 25 | fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) 26 | fmt.Printf("Hostname: %s\n", hostname) 27 | }, 28 | } 29 | 30 | func init() { 31 | RootCmd.AddCommand(versionCmd) 32 | } 33 | -------------------------------------------------------------------------------- /signer/signer.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/sha1" 8 | "encoding/base64" 9 | "errors" 10 | ) 11 | 12 | var randomReader = rand.Reader 13 | 14 | type Signer struct { 15 | Key *rsa.PrivateKey 16 | } 17 | 18 | func (s *Signer) SignTextures(textures string) (string, error) { 19 | if s.Key == nil { 20 | return "", errors.New("Key is empty") 21 | } 22 | 23 | message := []byte(textures) 24 | messageHash := sha1.New() 25 | _, _ = messageHash.Write(message) 26 | messageHashSum := messageHash.Sum(nil) 27 | 28 | signature, err := rsa.SignPKCS1v15(randomReader, s.Key, crypto.SHA1, messageHashSum) 29 | if err != nil { 30 | panic(err) 31 | } 32 | 33 | return base64.StdEncoding.EncodeToString(signature), nil 34 | } 35 | 36 | func (s *Signer) GetPublicKey() (*rsa.PublicKey, error) { 37 | if s.Key == nil { 38 | return nil, errors.New("Key is empty") 39 | } 40 | 41 | return &s.Key.PublicKey, nil 42 | } 43 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | # This file can be used to run application in the production environment. 2 | # Copy it into the docker-compose.yml: 3 | # > cp docker-compose.prod.yml docker-compose.yml 4 | # And then run it: 5 | # > docker-compose up -d 6 | # Service will be listened at the http://localhost 7 | 8 | version: '2' 9 | services: 10 | app: 11 | image: elyby/chrly 12 | hostname: chrly0 13 | restart: always 14 | links: 15 | - redis 16 | volumes: 17 | - ./data/capes:/data/capes 18 | ports: 19 | - "80:80" 20 | environment: 21 | CHRLY_SECRET: replace_this_value_in_production 22 | 23 | # Use this configuration in case when you need a remote Mojang UUIDs provider 24 | # worker: 25 | # image: elyby/chrly 26 | # hostname: chrly0 27 | # restart: always 28 | # ports: 29 | # - "8080:80" 30 | # command: ["worker"] 31 | 32 | redis: 33 | image: redis:4.0-32bit # 32-bit version is recommended to spare some memory 34 | restart: always 35 | volumes: 36 | - ./data/redis:/data 37 | -------------------------------------------------------------------------------- /di/dispatcher.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "github.com/defval/di" 5 | "github.com/mono83/slf" 6 | 7 | d "github.com/elyby/chrly/dispatcher" 8 | "github.com/elyby/chrly/eventsubscribers" 9 | "github.com/elyby/chrly/http" 10 | "github.com/elyby/chrly/mojangtextures" 11 | ) 12 | 13 | var dispatcher = di.Options( 14 | di.Provide(newDispatcher, 15 | di.As(new(d.Emitter)), 16 | di.As(new(d.Subscriber)), 17 | di.As(new(http.Emitter)), 18 | di.As(new(mojangtextures.Emitter)), 19 | di.As(new(eventsubscribers.Subscriber)), 20 | ), 21 | di.Invoke(enableEventsHandlers), 22 | ) 23 | 24 | func newDispatcher() d.Dispatcher { 25 | return d.New() 26 | } 27 | 28 | func enableEventsHandlers( 29 | dispatcher d.Subscriber, 30 | logger slf.Logger, 31 | statsReporter slf.StatsReporter, 32 | ) { 33 | // TODO: use idea from https://github.com/defval/di/issues/10#issuecomment-615869852 34 | (&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher) 35 | (&eventsubscribers.StatsReporter{StatsReporter: statsReporter}).ConfigureWithDispatcher(dispatcher) 36 | } 37 | -------------------------------------------------------------------------------- /di/signer.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/base64" 6 | "encoding/pem" 7 | "errors" 8 | "github.com/elyby/chrly/http" 9 | . "github.com/elyby/chrly/signer" 10 | "strings" 11 | 12 | "github.com/defval/di" 13 | "github.com/spf13/viper" 14 | ) 15 | 16 | var signer = di.Options( 17 | di.Provide(newTexturesSigner, 18 | di.As(new(http.TexturesSigner)), 19 | ), 20 | ) 21 | 22 | func newTexturesSigner(config *viper.Viper) (*Signer, error) { 23 | keyStr := config.GetString("chrly.signing.key") 24 | if keyStr == "" { 25 | return nil, errors.New("chrly.signing.key must be set in order to sign textures") 26 | } 27 | 28 | var keyBytes []byte 29 | if strings.HasPrefix(keyStr, "base64:") { 30 | base64Value := keyStr[7:] 31 | decodedKey, err := base64.URLEncoding.DecodeString(base64Value) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | keyBytes = decodedKey 37 | } else { 38 | keyBytes = []byte(keyStr) 39 | } 40 | 41 | rawPem, _ := pem.Decode(keyBytes) 42 | key, err := x509.ParsePKCS1PrivateKey(rawPem.Bytes) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return &Signer{Key: key}, nil 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - '*.*.*' 9 | pull_request: 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | redis: 17 | image: redis:7-alpine 18 | options: >- 19 | --health-cmd "redis-cli ping" 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 6379:6379 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | 29 | - name: Setup Go 30 | uses: actions/setup-go@v4 31 | with: 32 | cache-dependency-path: go.sum 33 | go-version-file: go.mod 34 | 35 | - name: Install dependencies 36 | run: go get . 37 | 38 | - name: Go Format 39 | run: gofmt -s -w . && git diff --exit-code 40 | 41 | - name: Go Vet 42 | run: go vet ./... 43 | 44 | - name: Go Test 45 | run: go test -v -race --tags redis -coverprofile=coverage.txt -covermode=atomic ./... 46 | 47 | - name: Upload coverage to Codecov 48 | uses: codecov/codecov-action@v4-beta 49 | env: 50 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 51 | 52 | - name: Build 53 | run: go build ./... 54 | -------------------------------------------------------------------------------- /cmd/root_profiling.go: -------------------------------------------------------------------------------- 1 | //go:build profiling 2 | // +build profiling 3 | 4 | package cmd 5 | 6 | import ( 7 | "log" 8 | "os" 9 | "runtime/pprof" 10 | 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func init() { 15 | var profilePath string 16 | RootCmd.PersistentFlags().StringVar(&profilePath, "cpuprofile", "", "enables pprof profiling and sets its output path") 17 | 18 | pprofEnabled := false 19 | originalPersistentPreRunE := RootCmd.PersistentPreRunE 20 | RootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { 21 | if profilePath == "" { 22 | return nil 23 | } 24 | 25 | f, err := os.Create(profilePath) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | log.Println("enabling profiling") 31 | err = pprof.StartCPUProfile(f) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | pprofEnabled = true 37 | 38 | if originalPersistentPreRunE != nil { 39 | return originalPersistentPreRunE(cmd, args) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | originalPersistentPostRun := RootCmd.PersistentPreRun 46 | RootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) { 47 | if pprofEnabled { 48 | log.Println("shutting down profiling") 49 | pprof.StopCPUProfile() 50 | } 51 | 52 | if originalPersistentPostRun != nil { 53 | originalPersistentPostRun(cmd, args) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /api/mojang/textures.go: -------------------------------------------------------------------------------- 1 | package mojang 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | ) 7 | 8 | type TexturesProp struct { 9 | Timestamp int64 `json:"timestamp"` 10 | ProfileID string `json:"profileId"` 11 | ProfileName string `json:"profileName"` 12 | Textures *TexturesResponse `json:"textures"` 13 | } 14 | 15 | type TexturesResponse struct { 16 | Skin *SkinTexturesResponse `json:"SKIN,omitempty"` 17 | Cape *CapeTexturesResponse `json:"CAPE,omitempty"` 18 | } 19 | 20 | type SkinTexturesResponse struct { 21 | Url string `json:"url"` 22 | Metadata *SkinTexturesMetadata `json:"metadata,omitempty"` 23 | } 24 | 25 | type SkinTexturesMetadata struct { 26 | Model string `json:"model"` 27 | } 28 | 29 | type CapeTexturesResponse struct { 30 | Url string `json:"url"` 31 | } 32 | 33 | func DecodeTextures(encodedTextures string) (*TexturesProp, error) { 34 | jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | var result *TexturesProp 40 | err = json.Unmarshal(jsonStr, &result) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | return result, nil 46 | } 47 | 48 | func EncodeTextures(textures *TexturesProp) string { 49 | jsonSerialized, _ := json.Marshal(textures) 50 | return base64.URLEncoding.EncodeToString(jsonSerialized) 51 | } 52 | -------------------------------------------------------------------------------- /http/uuids_worker.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/elyby/chrly/api/mojang" 10 | ) 11 | 12 | type MojangUuidsProvider interface { 13 | GetUuid(username string) (*mojang.ProfileInfo, error) 14 | } 15 | 16 | type UUIDsWorker struct { 17 | MojangUuidsProvider 18 | } 19 | 20 | func (ctx *UUIDsWorker) Handler() *mux.Router { 21 | router := mux.NewRouter().StrictSlash(true) 22 | router.Handle("/mojang-uuid/{username}", http.HandlerFunc(ctx.getUUIDHandler)).Methods("GET") 23 | 24 | return router 25 | } 26 | 27 | func (ctx *UUIDsWorker) getUUIDHandler(response http.ResponseWriter, request *http.Request) { 28 | username := mux.Vars(request)["username"] 29 | profile, err := ctx.GetUuid(username) 30 | if err != nil { 31 | if _, ok := err.(*mojang.TooManyRequestsError); ok { 32 | response.WriteHeader(http.StatusTooManyRequests) 33 | return 34 | } 35 | 36 | response.Header().Set("Content-Type", "application/json") 37 | response.WriteHeader(http.StatusInternalServerError) 38 | result, _ := json.Marshal(map[string]interface{}{ 39 | "provider": err.Error(), 40 | }) 41 | _, _ = response.Write(result) 42 | return 43 | } 44 | 45 | if profile == nil { 46 | response.WriteHeader(http.StatusNoContent) 47 | return 48 | } 49 | 50 | response.Header().Set("Content-Type", "application/json") 51 | responseData, _ := json.Marshal(profile) 52 | _, _ = response.Write(responseData) 53 | } 54 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | . "github.com/defval/di" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | 13 | "github.com/elyby/chrly/di" 14 | "github.com/elyby/chrly/http" 15 | "github.com/elyby/chrly/version" 16 | ) 17 | 18 | var RootCmd = &cobra.Command{ 19 | Use: "chrly", 20 | Short: "Implementation of Minecraft skins system server", 21 | Version: version.Version(), 22 | } 23 | 24 | // Execute adds all child commands to the root command and sets flags appropriately. 25 | // This is called by main.main(). It only needs to happen once to the rootCmd. 26 | func Execute() { 27 | if err := RootCmd.Execute(); err != nil { 28 | fmt.Println(err) 29 | os.Exit(1) 30 | } 31 | } 32 | 33 | func shouldGetContainer() *Container { 34 | container, err := di.New() 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | return container 40 | } 41 | 42 | func startServer(modules []string) { 43 | container := shouldGetContainer() 44 | 45 | var config *viper.Viper 46 | err := container.Resolve(&config) 47 | if err != nil { 48 | log.Fatal(err) 49 | } 50 | 51 | config.Set("modules", modules) 52 | 53 | err = container.Invoke(http.StartServer) 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | } 58 | 59 | func init() { 60 | cobra.OnInitialize(initConfig) 61 | } 62 | 63 | func initConfig() { 64 | viper.AutomaticEnv() 65 | replacer := strings.NewReplacer(".", "_") 66 | viper.SetEnvKeyReplacer(replacer) 67 | } 68 | -------------------------------------------------------------------------------- /db/fs/fs_integration_test.go: -------------------------------------------------------------------------------- 1 | package fs 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNew(t *testing.T) { 14 | fs, err := New("base/path") 15 | require.Nil(t, err) 16 | require.Equal(t, "base/path", fs.path) 17 | } 18 | 19 | func TestFilesystem(t *testing.T) { 20 | t.Run("FindCapeByUsername", func(t *testing.T) { 21 | dir, err := ioutil.TempDir("", "capes") 22 | if err != nil { 23 | panic(fmt.Errorf("cannot crete temp directory for tests: %w", err)) 24 | } 25 | defer os.RemoveAll(dir) 26 | 27 | t.Run("exists cape", func(t *testing.T) { 28 | file, err := os.Create(path.Join(dir, "username.png")) 29 | if err != nil { 30 | panic(fmt.Errorf("cannot create temp skin for tests: %w", err)) 31 | } 32 | defer os.Remove(file.Name()) 33 | 34 | fs, _ := New(dir) 35 | cape, err := fs.FindCapeByUsername("username") 36 | require.Nil(t, err) 37 | require.NotNil(t, cape) 38 | capeFile, _ := cape.File.(*os.File) 39 | require.Equal(t, file.Name(), capeFile.Name()) 40 | }) 41 | 42 | t.Run("not exists cape", func(t *testing.T) { 43 | fs, _ := New(dir) 44 | cape, err := fs.FindCapeByUsername("username") 45 | require.Nil(t, err) 46 | require.Nil(t, cape) 47 | }) 48 | 49 | t.Run("empty username", func(t *testing.T) { 50 | fs, _ := New(dir) 51 | cape, err := fs.FindCapeByUsername("") 52 | require.Nil(t, err) 53 | require.Nil(t, cape) 54 | }) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /mojangtextures/remote_api_uuids_provider.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | . "net/url" 8 | "path" 9 | 10 | "github.com/elyby/chrly/api/mojang" 11 | "github.com/elyby/chrly/version" 12 | ) 13 | 14 | var HttpClient = &http.Client{ 15 | Transport: &http.Transport{ 16 | MaxIdleConnsPerHost: 1024, 17 | }, 18 | } 19 | 20 | type RemoteApiUuidsProvider struct { 21 | Emitter 22 | Url URL 23 | } 24 | 25 | func (ctx *RemoteApiUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) { 26 | url := ctx.Url 27 | url.Path = path.Join(url.Path, username) 28 | urlStr := url.String() 29 | 30 | request, _ := http.NewRequest("GET", urlStr, nil) 31 | request.Header.Add("Accept", "application/json") 32 | // Change default User-Agent to allow specify "Username -> UUID at time" Mojang's api endpoint 33 | request.Header.Add("User-Agent", "Chrly/"+version.Version()) 34 | 35 | ctx.Emit("mojang_textures:remote_api_uuids_provider:before_request", urlStr) 36 | response, err := HttpClient.Do(request) 37 | ctx.Emit("mojang_textures:remote_api_uuids_provider:after_request", response, err) 38 | if err != nil { 39 | return nil, err 40 | } 41 | defer response.Body.Close() 42 | 43 | if response.StatusCode == 204 { 44 | return nil, nil 45 | } 46 | 47 | if response.StatusCode != 200 { 48 | return nil, &UnexpectedRemoteApiResponse{response} 49 | } 50 | 51 | var result *mojang.ProfileInfo 52 | body, _ := ioutil.ReadAll(response.Body) 53 | err = json.Unmarshal(body, &result) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return result, nil 59 | } 60 | 61 | type UnexpectedRemoteApiResponse struct { 62 | Response *http.Response 63 | } 64 | 65 | func (*UnexpectedRemoteApiResponse) Error() string { 66 | return "Unexpected remote api response" 67 | } 68 | -------------------------------------------------------------------------------- /http/jwt.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/SermoDigital/jose/crypto" 10 | "github.com/SermoDigital/jose/jws" 11 | ) 12 | 13 | var hashAlg = crypto.SigningMethodHS256 14 | 15 | const scopesClaim = "scopes" 16 | 17 | type Scope string 18 | 19 | var ( 20 | SkinScope = Scope("skin") 21 | ) 22 | 23 | type JwtAuth struct { 24 | Emitter 25 | Key []byte 26 | } 27 | 28 | func (t *JwtAuth) NewToken(scopes ...Scope) ([]byte, error) { 29 | if len(t.Key) == 0 { 30 | return nil, errors.New("signing key not available") 31 | } 32 | 33 | claims := jws.Claims{} 34 | claims.Set(scopesClaim, scopes) 35 | claims.SetIssuedAt(time.Now()) 36 | encoder := jws.NewJWT(claims, hashAlg) 37 | token, err := encoder.Serialize(t.Key) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return token, nil 43 | } 44 | 45 | func (t *JwtAuth) Authenticate(req *http.Request) error { 46 | if len(t.Key) == 0 { 47 | return t.emitErr(errors.New("Signing key not set")) 48 | } 49 | 50 | bearerToken := req.Header.Get("Authorization") 51 | if bearerToken == "" { 52 | return t.emitErr(errors.New("Authentication header not presented")) 53 | } 54 | 55 | if !strings.EqualFold(bearerToken[0:7], "BEARER ") { 56 | return t.emitErr(errors.New("Cannot recognize JWT token in passed value")) 57 | } 58 | 59 | tokenStr := bearerToken[7:] 60 | token, err := jws.ParseJWT([]byte(tokenStr)) 61 | if err != nil { 62 | return t.emitErr(errors.New("Cannot parse passed JWT token")) 63 | } 64 | 65 | err = token.Validate(t.Key, hashAlg) 66 | if err != nil { 67 | return t.emitErr(errors.New("JWT token have invalid signature. It may be corrupted or expired")) 68 | } 69 | 70 | t.Emit("authentication:success") 71 | 72 | return nil 73 | } 74 | 75 | func (t *JwtAuth) emitErr(err error) error { 76 | t.Emit("authentication:error", err) 77 | return err 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Build 7 | types: 8 | - completed 9 | branches: 10 | - master 11 | 12 | jobs: 13 | dockerhub: 14 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - id: meta 22 | name: Docker meta 23 | uses: docker/metadata-action@v5 24 | with: 25 | images: ${{ github.repository }} 26 | tags: | 27 | type=semver,pattern={{version}} 28 | type=semver,pattern={{major}}.{{minor}} 29 | type=semver,pattern={{major}} 30 | type=edge,branch=${{ github.event.repository.default_branch }} 31 | 32 | - id: version 33 | name: Set up build version 34 | run: | 35 | if [[ $GITHUB_REF_TYPE == "tag" ]]; then 36 | VERSION=${GITHUB_REF#refs/tags/} 37 | else 38 | BRANCH_NAME=${GITHUB_REF#refs/heads/} 39 | SHORT_SHA=$(git rev-parse --short $GITHUB_SHA) 40 | VERSION="${BRANCH_NAME}-${SHORT_SHA}" 41 | fi 42 | echo "### Version: $VERSION" >> $GITHUB_STEP_SUMMARY 43 | echo "version=$VERSION" >> "$GITHUB_OUTPUT" 44 | 45 | - name: Set up QEMU 46 | uses: docker/setup-qemu-action@v3 47 | 48 | - name: Set up Docker Buildx 49 | uses: docker/setup-buildx-action@v3 50 | 51 | - name: Log in to the Container registry 52 | uses: docker/login-action@v3 53 | with: 54 | username: ${{ secrets.DOCKERHUB_USERNAME }} 55 | password: ${{ secrets.DOCKERHUB_TOKEN }} 56 | 57 | - name: Build and Push 58 | uses: docker/build-push-action@v5 59 | with: 60 | push: true 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | platforms: linux/amd64,linux/arm64 64 | build-args: | 65 | VERSION=${{ steps.version.outputs.version }} 66 | COMMIT=${{ github.sha }} 67 | -------------------------------------------------------------------------------- /mojangtextures/storage.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "github.com/elyby/chrly/api/mojang" 5 | ) 6 | 7 | // UUIDsStorage is a key-value storage of Mojang usernames pairs to its UUIDs, 8 | // used to reduce the load on the account information queue 9 | type UUIDsStorage interface { 10 | // The second argument indicates whether a record was found in the storage, 11 | // since depending on it, the empty value must be interpreted as "no cached record" 12 | // or "value cached and has an empty value" 13 | GetUuid(username string) (uuid string, found bool, err error) 14 | // An empty uuid value can be passed if the corresponding account has not been found 15 | StoreUuid(username string, uuid string) error 16 | } 17 | 18 | // TexturesStorage is a Mojang's textures storage, used as a values cache to avoid 429 errors 19 | type TexturesStorage interface { 20 | // Error should not have nil value only if the repository failed to determine if there are any textures 21 | // for this uuid or not at all. If there is information about the absence of textures, nil nil should be returned 22 | GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) 23 | // The nil value can be passed when there are no textures for the corresponding uuid and we know about it 24 | StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) 25 | } 26 | 27 | type Storage interface { 28 | UUIDsStorage 29 | TexturesStorage 30 | } 31 | 32 | // SeparatedStorage allows you to use separate storage engines to satisfy 33 | // the Storage interface 34 | type SeparatedStorage struct { 35 | UUIDsStorage 36 | TexturesStorage 37 | } 38 | 39 | func (s *SeparatedStorage) GetUuid(username string) (string, bool, error) { 40 | return s.UUIDsStorage.GetUuid(username) 41 | } 42 | 43 | func (s *SeparatedStorage) StoreUuid(username string, uuid string) error { 44 | return s.UUIDsStorage.StoreUuid(username, uuid) 45 | } 46 | 47 | func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { 48 | return s.TexturesStorage.GetTextures(uuid) 49 | } 50 | 51 | func (s *SeparatedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) { 52 | s.TexturesStorage.StoreTextures(uuid, textures) 53 | } 54 | -------------------------------------------------------------------------------- /di/db.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path" 7 | 8 | "github.com/defval/di" 9 | "github.com/spf13/viper" 10 | 11 | "github.com/elyby/chrly/db/fs" 12 | "github.com/elyby/chrly/db/redis" 13 | es "github.com/elyby/chrly/eventsubscribers" 14 | "github.com/elyby/chrly/http" 15 | "github.com/elyby/chrly/mojangtextures" 16 | ) 17 | 18 | // v4 had the idea that it would be possible to separate backends for storing skins and capes. 19 | // But in v5 the storage will be unified, so this is just temporary constructors before large reworking. 20 | // 21 | // Since there are no options for selecting target backends, 22 | // all constants in this case point to static specific implementations. 23 | var db = di.Options( 24 | di.Provide(newRedis, 25 | di.As(new(http.SkinsRepository)), 26 | di.As(new(mojangtextures.UUIDsStorage)), 27 | ), 28 | di.Provide(newFSFactory, 29 | di.As(new(http.CapesRepository)), 30 | ), 31 | di.Provide(newMojangSignedTexturesStorage), 32 | ) 33 | 34 | func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) { 35 | config.SetDefault("storage.redis.host", "localhost") 36 | config.SetDefault("storage.redis.port", 6379) 37 | config.SetDefault("storage.redis.poolSize", 10) 38 | 39 | conn, err := redis.New( 40 | context.Background(), 41 | fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")), 42 | config.GetInt("storage.redis.poolSize"), 43 | ) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | if err := container.Provide(func() *namedHealthChecker { 49 | return &namedHealthChecker{ 50 | Name: "redis", 51 | Checker: es.DatabaseChecker(conn), 52 | } 53 | }); err != nil { 54 | return nil, err 55 | } 56 | 57 | return conn, nil 58 | } 59 | 60 | func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) { 61 | config.SetDefault("storage.filesystem.basePath", "data") 62 | config.SetDefault("storage.filesystem.capesDirName", "capes") 63 | 64 | return fs.New(path.Join( 65 | config.GetString("storage.filesystem.basePath"), 66 | config.GetString("storage.filesystem.capesDirName"), 67 | )) 68 | } 69 | 70 | func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage { 71 | return mojangtextures.NewInMemoryTexturesStorage() 72 | } 73 | -------------------------------------------------------------------------------- /mojangtextures/in_memory_textures_storage.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/elyby/chrly/api/mojang" 8 | "github.com/elyby/chrly/utils" 9 | ) 10 | 11 | type inMemoryItem struct { 12 | textures *mojang.SignedTexturesResponse 13 | timestamp int64 14 | } 15 | 16 | type InMemoryTexturesStorage struct { 17 | GCPeriod time.Duration 18 | Duration time.Duration 19 | 20 | once sync.Once 21 | lock sync.RWMutex 22 | data map[string]*inMemoryItem 23 | done chan struct{} 24 | } 25 | 26 | func NewInMemoryTexturesStorage() *InMemoryTexturesStorage { 27 | storage := &InMemoryTexturesStorage{ 28 | GCPeriod: 10 * time.Second, 29 | Duration: time.Minute + 10*time.Second, 30 | data: make(map[string]*inMemoryItem), 31 | } 32 | 33 | return storage 34 | } 35 | 36 | func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { 37 | s.lock.RLock() 38 | defer s.lock.RUnlock() 39 | 40 | item, exists := s.data[uuid] 41 | validRange := s.getMinimalNotExpiredTimestamp() 42 | if !exists || validRange > item.timestamp { 43 | return nil, nil 44 | } 45 | 46 | return item.textures, nil 47 | } 48 | 49 | func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) { 50 | s.once.Do(s.start) 51 | 52 | s.lock.Lock() 53 | defer s.lock.Unlock() 54 | 55 | s.data[uuid] = &inMemoryItem{ 56 | textures: textures, 57 | timestamp: utils.UnixMillisecond(time.Now()), 58 | } 59 | } 60 | 61 | func (s *InMemoryTexturesStorage) start() { 62 | s.done = make(chan struct{}) 63 | ticker := time.NewTicker(s.GCPeriod) 64 | go func() { 65 | for { 66 | select { 67 | case <-s.done: 68 | return 69 | case <-ticker.C: 70 | s.gc() 71 | } 72 | } 73 | }() 74 | } 75 | 76 | func (s *InMemoryTexturesStorage) Stop() { 77 | close(s.done) 78 | } 79 | 80 | func (s *InMemoryTexturesStorage) gc() { 81 | s.lock.Lock() 82 | defer s.lock.Unlock() 83 | 84 | maxTime := s.getMinimalNotExpiredTimestamp() 85 | for uuid, value := range s.data { 86 | if maxTime > value.timestamp { 87 | delete(s.data, uuid) 88 | } 89 | } 90 | } 91 | 92 | func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 { 93 | return utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1))) 94 | } 95 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elyby/chrly 2 | 3 | go 1.21 4 | 5 | replace github.com/asaskevich/EventBus v0.0.0-20200330115301-33b3bc6a7ddc => github.com/erickskrauch/EventBus v0.0.0-20200330115301-33b3bc6a7ddc 6 | 7 | // Main dependencies 8 | require ( 9 | github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2 10 | github.com/asaskevich/EventBus v0.0.0-20200330115301-33b3bc6a7ddc 11 | github.com/defval/di v1.12.0 12 | github.com/etherlabsio/healthcheck/v2 v2.0.0 13 | github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea 14 | github.com/gorilla/mux v1.8.1 15 | github.com/mediocregopher/radix/v4 v4.1.4 16 | github.com/mono83/slf v0.0.0-20170919161409-79153e9636db 17 | github.com/spf13/cobra v1.8.0 18 | github.com/spf13/viper v1.18.1 19 | github.com/thedevsaddam/govalidator v1.9.10 20 | ) 21 | 22 | // Dev dependencies 23 | require ( 24 | github.com/h2non/gock v1.2.0 25 | github.com/stretchr/testify v1.8.4 26 | ) 27 | 28 | require ( 29 | github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/fsnotify/fsnotify v1.7.0 // indirect 32 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect 33 | github.com/hashicorp/hcl v1.0.0 // indirect 34 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 35 | github.com/magiconair/properties v1.8.7 // indirect 36 | github.com/mitchellh/mapstructure v1.5.0 // indirect 37 | github.com/mono83/udpwriter v1.0.2 // indirect 38 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/sagikazarmark/locafero v0.4.0 // indirect 41 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 42 | github.com/sourcegraph/conc v0.3.0 // indirect 43 | github.com/spf13/afero v1.11.0 // indirect 44 | github.com/spf13/cast v1.6.0 // indirect 45 | github.com/spf13/pflag v1.0.5 // indirect 46 | github.com/stretchr/objx v0.5.0 // indirect 47 | github.com/subosito/gotenv v1.6.0 // indirect 48 | github.com/tilinna/clock v1.0.2 // indirect 49 | go.uber.org/multierr v1.11.0 // indirect 50 | golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect 51 | golang.org/x/sys v0.15.0 // indirect 52 | golang.org/x/text v0.14.0 // indirect 53 | gopkg.in/ini.v1 v1.67.0 // indirect 54 | gopkg.in/yaml.v3 v3.0.1 // indirect 55 | ) 56 | -------------------------------------------------------------------------------- /di/server.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "runtime/debug" 8 | "time" 9 | 10 | "github.com/defval/di" 11 | "github.com/getsentry/raven-go" 12 | "github.com/spf13/viper" 13 | 14 | . "github.com/elyby/chrly/http" 15 | ) 16 | 17 | var server = di.Options( 18 | di.Provide(newAuthenticator, di.As(new(Authenticator))), 19 | di.Provide(newServer), 20 | ) 21 | 22 | func newAuthenticator(config *viper.Viper, emitter Emitter) (*JwtAuth, error) { 23 | key := config.GetString("chrly.secret") 24 | if key == "" { 25 | return nil, errors.New("chrly.secret must be set in order to use authenticator") 26 | } 27 | 28 | return &JwtAuth{ 29 | Key: []byte(key), 30 | Emitter: emitter, 31 | }, nil 32 | } 33 | 34 | type serverParams struct { 35 | di.Inject 36 | 37 | Config *viper.Viper `di:""` 38 | Handler http.Handler `di:""` 39 | Sentry *raven.Client `di:"" optional:"true"` 40 | } 41 | 42 | func newServer(params serverParams) *http.Server { 43 | params.Config.SetDefault("server.host", "") 44 | params.Config.SetDefault("server.port", 80) 45 | 46 | var handler http.Handler 47 | if params.Sentry != nil { 48 | // raven.Recoverer uses DefaultClient and nothing can be done about it 49 | // To avoid code duplication, if the Sentry service is successfully initiated, 50 | // it will also replace DefaultClient, so raven.Recoverer will work with the instance 51 | // created in the application constructor 52 | handler = raven.Recoverer(params.Handler) 53 | } else { 54 | // Raven's Recoverer is prints the stacktrace and sets the corresponding status itself. 55 | // But there is no magic and if you don't define a panic handler, Mux will just reset the connection 56 | handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) { 57 | defer func() { 58 | if recovered := recover(); recovered != nil { 59 | debug.PrintStack() // TODO: colorize output 60 | request.WriteHeader(http.StatusInternalServerError) 61 | } 62 | }() 63 | 64 | params.Handler.ServeHTTP(request, response) 65 | }) 66 | } 67 | 68 | address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port")) 69 | server := &http.Server{ 70 | Addr: address, 71 | ReadTimeout: 5 * time.Second, 72 | WriteTimeout: 5 * time.Second, 73 | IdleTimeout: 60 * time.Second, 74 | MaxHeaderBytes: 1 << 16, 75 | Handler: handler, 76 | } 77 | 78 | return server 79 | } 80 | -------------------------------------------------------------------------------- /di/logger.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/defval/di" 7 | "github.com/getsentry/raven-go" 8 | "github.com/mono83/slf" 9 | "github.com/mono83/slf/rays" 10 | "github.com/mono83/slf/recievers/sentry" 11 | "github.com/mono83/slf/recievers/statsd" 12 | "github.com/mono83/slf/recievers/writer" 13 | "github.com/mono83/slf/wd" 14 | "github.com/spf13/viper" 15 | 16 | "github.com/elyby/chrly/eventsubscribers" 17 | "github.com/elyby/chrly/version" 18 | ) 19 | 20 | var logger = di.Options( 21 | di.Provide(newLogger), 22 | di.Provide(newSentry), 23 | di.Provide(newStatsReporter), 24 | ) 25 | 26 | type loggerParams struct { 27 | di.Inject 28 | 29 | SentryRaven *raven.Client `di:"" optional:"true"` 30 | } 31 | 32 | func newLogger(params loggerParams) slf.Logger { 33 | dispatcher := &slf.Dispatcher{} 34 | dispatcher.AddReceiver(writer.New(writer.Options{ 35 | Marker: false, 36 | TimeFormat: "15:04:05.000", 37 | })) 38 | 39 | if params.SentryRaven != nil { 40 | sentryReceiver, _ := sentry.NewReceiverWithCustomRaven( 41 | params.SentryRaven, 42 | &sentry.Config{ 43 | MinLevel: "warn", 44 | }, 45 | ) 46 | dispatcher.AddReceiver(sentryReceiver) 47 | } 48 | 49 | logger := wd.Custom("", "", dispatcher) 50 | logger.WithParams(rays.Host) 51 | 52 | return logger 53 | } 54 | 55 | func newSentry(config *viper.Viper) (*raven.Client, error) { 56 | sentryAddr := config.GetString("sentry.dsn") 57 | if sentryAddr == "" { 58 | return nil, nil 59 | } 60 | 61 | ravenClient, err := raven.New(sentryAddr) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | ravenClient.SetEnvironment("production") 67 | ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver") 68 | ravenClient.SetRelease(version.Version()) 69 | 70 | raven.DefaultClient = ravenClient 71 | 72 | return ravenClient, nil 73 | } 74 | 75 | func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) { 76 | dispatcher := &slf.Dispatcher{} 77 | 78 | statsdAddr := config.GetString("statsd.addr") 79 | if statsdAddr != "" { 80 | hostname, err := os.Hostname() 81 | if err != nil { 82 | return nil, err 83 | } 84 | 85 | statsdReceiver, err := statsd.NewReceiver(statsd.Config{ 86 | Address: statsdAddr, 87 | Prefix: "ely.skinsystem." + hostname + ".app.", 88 | FlushEvery: 1, 89 | }) 90 | if err != nil { 91 | return nil, err 92 | } 93 | 94 | dispatcher.AddReceiver(statsdReceiver) 95 | } 96 | 97 | return wd.Custom("", "", dispatcher), nil 98 | } 99 | 100 | func enableReporters(reporter slf.StatsReporter, factories []eventsubscribers.Reporter) { 101 | for _, factory := range factories { 102 | factory.Enable(reporter) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /eventsubscribers/logger.go: -------------------------------------------------------------------------------- 1 | package eventsubscribers 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "syscall" 9 | 10 | "github.com/mono83/slf" 11 | "github.com/mono83/slf/wd" 12 | 13 | "github.com/elyby/chrly/api/mojang" 14 | ) 15 | 16 | type Logger struct { 17 | slf.Logger 18 | } 19 | 20 | func (l *Logger) ConfigureWithDispatcher(d Subscriber) { 21 | d.Subscribe("skinsystem:after_request", l.handleAfterSkinsystemRequest) 22 | 23 | d.Subscribe("mojang_textures:usernames:after_call", l.createMojangTexturesErrorHandler("usernames")) 24 | d.Subscribe("mojang_textures:textures:after_call", l.createMojangTexturesErrorHandler("textures")) 25 | } 26 | 27 | func (l *Logger) handleAfterSkinsystemRequest(req *http.Request, statusCode int) { 28 | path := req.URL.Path 29 | if req.URL.RawQuery != "" { 30 | path += "?" + req.URL.RawQuery 31 | } 32 | 33 | l.Info( 34 | ":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"", 35 | wd.StringParam("ip", trimPort(req.RemoteAddr)), 36 | wd.StringParam("method", req.Method), 37 | wd.StringParam("path", path), 38 | wd.IntParam("statusCode", statusCode), 39 | wd.StringParam("userAgent", req.UserAgent()), 40 | wd.StringParam("forwardedIp", req.Header.Get("X-Forwarded-For")), 41 | ) 42 | } 43 | 44 | func (l *Logger) createMojangTexturesErrorHandler(provider string) func(identity string, result interface{}, err error) { 45 | providerParam := wd.NameParam(provider) 46 | return func(identity string, result interface{}, err error) { 47 | if err == nil { 48 | return 49 | } 50 | 51 | errParam := wd.ErrParam(err) 52 | 53 | switch err.(type) { 54 | case *mojang.BadRequestError: 55 | l.logMojangTexturesWarning(providerParam, errParam) 56 | return 57 | case *mojang.ForbiddenError: 58 | l.logMojangTexturesWarning(providerParam, errParam) 59 | return 60 | case *mojang.TooManyRequestsError: 61 | l.logMojangTexturesWarning(providerParam, errParam) 62 | return 63 | case net.Error: 64 | if err.(net.Error).Timeout() { 65 | return 66 | } 67 | 68 | if _, ok := err.(*url.Error); ok { 69 | return 70 | } 71 | 72 | if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") { 73 | return 74 | } 75 | 76 | if err == syscall.ECONNREFUSED { 77 | return 78 | } 79 | } 80 | 81 | l.Error(":name: Unexpected Mojang response error: :err", providerParam, errParam) 82 | } 83 | } 84 | 85 | func (l *Logger) logMojangTexturesWarning(providerParam slf.Param, errParam slf.Param) { 86 | l.Warning(":name: :err", providerParam, errParam) 87 | } 88 | 89 | func trimPort(ip string) string { 90 | // Don't care about possible -1 result because RemoteAddr will always contain ip and port 91 | cutTo := strings.LastIndexByte(ip, ':') 92 | 93 | return ip[0:cutTo] 94 | } 95 | -------------------------------------------------------------------------------- /mojangtextures/storage_test.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "github.com/elyby/chrly/api/mojang" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/mock" 8 | "testing" 9 | ) 10 | 11 | type uuidsStorageMock struct { 12 | mock.Mock 13 | } 14 | 15 | func (m *uuidsStorageMock) GetUuid(username string) (string, bool, error) { 16 | args := m.Called(username) 17 | return args.String(0), args.Bool(1), args.Error(2) 18 | } 19 | 20 | func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error { 21 | m.Called(username, uuid) 22 | return nil 23 | } 24 | 25 | type texturesStorageMock struct { 26 | mock.Mock 27 | } 28 | 29 | func (m *texturesStorageMock) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { 30 | args := m.Called(uuid) 31 | var result *mojang.SignedTexturesResponse 32 | if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok { 33 | result = casted 34 | } 35 | 36 | return result, args.Error(1) 37 | } 38 | 39 | func (m *texturesStorageMock) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) { 40 | m.Called(uuid, textures) 41 | } 42 | 43 | func TestSplittedStorage(t *testing.T) { 44 | createMockedStorage := func() (*SeparatedStorage, *uuidsStorageMock, *texturesStorageMock) { 45 | uuidsStorage := &uuidsStorageMock{} 46 | texturesStorage := &texturesStorageMock{} 47 | 48 | return &SeparatedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage 49 | } 50 | 51 | t.Run("GetUuid", func(t *testing.T) { 52 | storage, uuidsMock, _ := createMockedStorage() 53 | uuidsMock.On("GetUuid", "username").Once().Return("find me", true, nil) 54 | result, found, err := storage.GetUuid("username") 55 | assert.Nil(t, err) 56 | assert.True(t, found) 57 | assert.Equal(t, "find me", result) 58 | uuidsMock.AssertExpectations(t) 59 | }) 60 | 61 | t.Run("StoreUuid", func(t *testing.T) { 62 | storage, uuidsMock, _ := createMockedStorage() 63 | uuidsMock.On("StoreUuid", "username", "result").Once() 64 | _ = storage.StoreUuid("username", "result") 65 | uuidsMock.AssertExpectations(t) 66 | }) 67 | 68 | t.Run("GetTextures", func(t *testing.T) { 69 | result := &mojang.SignedTexturesResponse{Id: "mock id"} 70 | storage, _, texturesMock := createMockedStorage() 71 | texturesMock.On("GetTextures", "uuid").Once().Return(result, nil) 72 | returned, err := storage.GetTextures("uuid") 73 | assert.Nil(t, err) 74 | assert.Equal(t, result, returned) 75 | texturesMock.AssertExpectations(t) 76 | }) 77 | 78 | t.Run("StoreTextures", func(t *testing.T) { 79 | toStore := &mojang.SignedTexturesResponse{} 80 | storage, _, texturesMock := createMockedStorage() 81 | texturesMock.On("StoreTextures", "mock id", toStore).Once() 82 | storage.StoreTextures("mock id", toStore) 83 | texturesMock.AssertExpectations(t) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /eventsubscribers/health_checkers.go: -------------------------------------------------------------------------------- 1 | package eventsubscribers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "sync" 7 | "time" 8 | 9 | "github.com/etherlabsio/healthcheck/v2" 10 | 11 | "github.com/elyby/chrly/api/mojang" 12 | ) 13 | 14 | type Pingable interface { 15 | Ping() error 16 | } 17 | 18 | func DatabaseChecker(connection Pingable) healthcheck.CheckerFunc { 19 | return func(ctx context.Context) error { 20 | done := make(chan error) 21 | go func() { 22 | done <- connection.Ping() 23 | }() 24 | 25 | select { 26 | case <-ctx.Done(): 27 | return errors.New("check timeout") 28 | case err := <-done: 29 | return err 30 | } 31 | } 32 | } 33 | 34 | func MojangBatchUuidsProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc { 35 | errHolder := &expiringErrHolder{D: resetDuration} 36 | dispatcher.Subscribe( 37 | "mojang_textures:batch_uuids_provider:result", 38 | func(usernames []string, profiles []*mojang.ProfileInfo, err error) { 39 | errHolder.Set(err) 40 | }, 41 | ) 42 | 43 | return func(ctx context.Context) error { 44 | return errHolder.Get() 45 | } 46 | } 47 | 48 | func MojangBatchUuidsProviderQueueLengthChecker(dispatcher Subscriber, maxLength int) healthcheck.CheckerFunc { 49 | var mutex sync.Mutex 50 | queueLength := 0 51 | dispatcher.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, tasksInQueue int) { 52 | mutex.Lock() 53 | queueLength = tasksInQueue 54 | mutex.Unlock() 55 | }) 56 | 57 | return func(ctx context.Context) error { 58 | mutex.Lock() 59 | defer mutex.Unlock() 60 | 61 | if queueLength < maxLength { 62 | return nil 63 | } 64 | 65 | return errors.New("the maximum number of tasks in the queue has been exceeded") 66 | } 67 | } 68 | 69 | func MojangApiTexturesProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc { 70 | errHolder := &expiringErrHolder{D: resetDuration} 71 | dispatcher.Subscribe( 72 | "mojang_textures:mojang_api_textures_provider:after_request", 73 | func(uuid string, profile *mojang.SignedTexturesResponse, err error) { 74 | errHolder.Set(err) 75 | }, 76 | ) 77 | 78 | return func(ctx context.Context) error { 79 | return errHolder.Get() 80 | } 81 | } 82 | 83 | type expiringErrHolder struct { 84 | D time.Duration 85 | err error 86 | l sync.Mutex 87 | t *time.Timer 88 | } 89 | 90 | func (h *expiringErrHolder) Get() error { 91 | h.l.Lock() 92 | defer h.l.Unlock() 93 | 94 | return h.err 95 | } 96 | 97 | func (h *expiringErrHolder) Set(err error) { 98 | h.l.Lock() 99 | defer h.l.Unlock() 100 | if h.t != nil { 101 | h.t.Stop() 102 | h.t = nil 103 | } 104 | 105 | h.err = err 106 | if err != nil { 107 | h.t = time.AfterFunc(h.D, func() { 108 | h.Set(nil) 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /signer/signer_test.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/pem" 7 | 8 | "testing" 9 | 10 | assert "github.com/stretchr/testify/require" 11 | ) 12 | 13 | type ConstantReader struct { 14 | } 15 | 16 | func (c *ConstantReader) Read(p []byte) (int, error) { 17 | return 1, nil 18 | } 19 | 20 | func TestSigner_SignTextures(t *testing.T) { 21 | randomReader = &ConstantReader{} 22 | 23 | t.Run("sign textures", func(t *testing.T) { 24 | rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n")) 25 | key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes) 26 | 27 | signer := &Signer{key} 28 | 29 | signature, err := signer.SignTextures("eyJ0aW1lc3RhbXAiOjE2MTQzMDcxMzQsInByb2ZpbGVJZCI6ImZmYzhmZGM5NTgyNDUwOWU4YTU3Yzk5Yjk0MGZiOTk2IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9lbHkuYnkvc3RvcmFnZS9za2lucy82OWM2NzQwZDI5OTNlNWQ2ZjZhN2ZjOTI0MjBlZmMyOS5wbmcifX0sImVseSI6dHJ1ZX0") 30 | assert.NoError(t, err) 31 | assert.Equal(t, "IyHCxTP5ITquEXTHcwCtLd08jWWy16JwlQeWg8naxhoAVQecHGRdzHRscuxtdq/446kmeox7h4EfRN2A2ZLL+A==", signature) 32 | }) 33 | 34 | t.Run("empty key", func(t *testing.T) { 35 | signer := &Signer{} 36 | 37 | signature, err := signer.SignTextures("hello world") 38 | assert.Error(t, err, "Key is empty") 39 | assert.Empty(t, signature) 40 | }) 41 | } 42 | 43 | func TestSigner_GetPublicKey(t *testing.T) { 44 | randomReader = &ConstantReader{} 45 | 46 | t.Run("get public key", func(t *testing.T) { 47 | rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n")) 48 | key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes) 49 | 50 | signer := &Signer{key} 51 | 52 | publicKey, err := signer.GetPublicKey() 53 | assert.NoError(t, err) 54 | assert.IsType(t, &rsa.PublicKey{}, publicKey) 55 | }) 56 | 57 | t.Run("empty key", func(t *testing.T) { 58 | signer := &Signer{} 59 | 60 | publicKey, err := signer.GetPublicKey() 61 | assert.Error(t, err, "Key is empty") 62 | assert.Nil(t, publicKey) 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /mojangtextures/mojang_api_textures_provider_test.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/mock" 7 | "github.com/stretchr/testify/suite" 8 | 9 | "github.com/elyby/chrly/api/mojang" 10 | ) 11 | 12 | type mojangUuidToTexturesRequestMock struct { 13 | mock.Mock 14 | } 15 | 16 | func (o *mojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) { 17 | args := o.Called(uuid, signed) 18 | var result *mojang.SignedTexturesResponse 19 | if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok { 20 | result = casted 21 | } 22 | 23 | return result, args.Error(1) 24 | } 25 | 26 | type mojangApiTexturesProviderTestSuite struct { 27 | suite.Suite 28 | 29 | Provider *MojangApiTexturesProvider 30 | Emitter *mockEmitter 31 | MojangApi *mojangUuidToTexturesRequestMock 32 | } 33 | 34 | func (suite *mojangApiTexturesProviderTestSuite) SetupTest() { 35 | suite.Emitter = &mockEmitter{} 36 | suite.MojangApi = &mojangUuidToTexturesRequestMock{} 37 | 38 | suite.Provider = &MojangApiTexturesProvider{ 39 | Emitter: suite.Emitter, 40 | } 41 | 42 | uuidToTextures = suite.MojangApi.UuidToTextures 43 | } 44 | 45 | func (suite *mojangApiTexturesProviderTestSuite) TearDownTest() { 46 | suite.MojangApi.AssertExpectations(suite.T()) 47 | suite.Emitter.AssertExpectations(suite.T()) 48 | } 49 | 50 | func TestMojangApiTexturesProvider(t *testing.T) { 51 | suite.Run(t, new(mojangApiTexturesProviderTestSuite)) 52 | } 53 | 54 | func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() { 55 | expectedResult := &mojang.SignedTexturesResponse{ 56 | Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 57 | Name: "username", 58 | } 59 | suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(expectedResult, nil) 60 | 61 | suite.Emitter.On("Emit", 62 | "mojang_textures:mojang_api_textures_provider:before_request", 63 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 64 | ).Once() 65 | suite.Emitter.On("Emit", 66 | "mojang_textures:mojang_api_textures_provider:after_request", 67 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 68 | expectedResult, 69 | nil, 70 | ).Once() 71 | 72 | result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 73 | 74 | suite.Assert().Equal(expectedResult, result) 75 | suite.Assert().Nil(err) 76 | } 77 | 78 | func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() { 79 | var expectedResponse *mojang.SignedTexturesResponse 80 | expectedError := &mojang.TooManyRequestsError{} 81 | suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError) 82 | 83 | suite.Emitter.On("Emit", 84 | "mojang_textures:mojang_api_textures_provider:before_request", 85 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 86 | ).Once() 87 | suite.Emitter.On("Emit", 88 | "mojang_textures:mojang_api_textures_provider:after_request", 89 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 90 | expectedResponse, 91 | expectedError, 92 | ).Once() 93 | 94 | result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 95 | 96 | suite.Assert().Nil(result) 97 | suite.Assert().Equal(expectedError, err) 98 | } 99 | -------------------------------------------------------------------------------- /http/http_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | testify "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/mock" 12 | ) 13 | 14 | type emitterMock struct { 15 | mock.Mock 16 | } 17 | 18 | func (e *emitterMock) Emit(name string, args ...interface{}) { 19 | e.Called(append([]interface{}{name}, args...)...) 20 | } 21 | 22 | func TestCreateRequestEventsMiddleware(t *testing.T) { 23 | req := httptest.NewRequest("GET", "http://example.com", nil) 24 | resp := httptest.NewRecorder() 25 | 26 | emitter := &emitterMock{} 27 | emitter.On("Emit", "test_prefix:before_request", req) 28 | emitter.On("Emit", "test_prefix:after_request", req, 400) 29 | 30 | isHandlerCalled := false 31 | middlewareFunc := CreateRequestEventsMiddleware(emitter, "test_prefix") 32 | middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 33 | resp.WriteHeader(400) 34 | isHandlerCalled = true 35 | })).ServeHTTP(resp, req) 36 | 37 | if !isHandlerCalled { 38 | t.Fatal("Handler isn't called from the middleware") 39 | } 40 | 41 | emitter.AssertExpectations(t) 42 | } 43 | 44 | type authCheckerMock struct { 45 | mock.Mock 46 | } 47 | 48 | func (m *authCheckerMock) Authenticate(req *http.Request) error { 49 | args := m.Called(req) 50 | return args.Error(0) 51 | } 52 | 53 | func TestCreateAuthenticationMiddleware(t *testing.T) { 54 | t.Run("pass", func(t *testing.T) { 55 | req := httptest.NewRequest("GET", "http://example.com", nil) 56 | resp := httptest.NewRecorder() 57 | 58 | auth := &authCheckerMock{} 59 | auth.On("Authenticate", req).Once().Return(nil) 60 | 61 | isHandlerCalled := false 62 | middlewareFunc := CreateAuthenticationMiddleware(auth) 63 | middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 64 | isHandlerCalled = true 65 | })).ServeHTTP(resp, req) 66 | 67 | testify.True(t, isHandlerCalled, "Handler isn't called from the middleware") 68 | 69 | auth.AssertExpectations(t) 70 | }) 71 | 72 | t.Run("fail", func(t *testing.T) { 73 | req := httptest.NewRequest("GET", "http://example.com", nil) 74 | resp := httptest.NewRecorder() 75 | 76 | auth := &authCheckerMock{} 77 | auth.On("Authenticate", req).Once().Return(errors.New("error reason")) 78 | 79 | isHandlerCalled := false 80 | middlewareFunc := CreateAuthenticationMiddleware(auth) 81 | middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 82 | isHandlerCalled = true 83 | })).ServeHTTP(resp, req) 84 | 85 | testify.False(t, isHandlerCalled, "Handler shouldn't be called") 86 | testify.Equal(t, 403, resp.Code) 87 | body, _ := ioutil.ReadAll(resp.Body) 88 | testify.JSONEq(t, `{ 89 | "error": "error reason" 90 | }`, string(body)) 91 | 92 | auth.AssertExpectations(t) 93 | }) 94 | } 95 | 96 | func TestNotFoundHandler(t *testing.T) { 97 | assert := testify.New(t) 98 | 99 | req := httptest.NewRequest("GET", "http://example.com", nil) 100 | w := httptest.NewRecorder() 101 | 102 | NotFoundHandler(w, req) 103 | 104 | resp := w.Result() 105 | assert.Equal(404, resp.StatusCode) 106 | assert.Equal("application/json", resp.Header.Get("Content-Type")) 107 | response, _ := ioutil.ReadAll(resp.Body) 108 | assert.JSONEq(`{ 109 | "status": "404", 110 | "message": "Not Found" 111 | }`, string(response)) 112 | } 113 | -------------------------------------------------------------------------------- /http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/gorilla/mux" 13 | "github.com/mono83/slf" 14 | "github.com/mono83/slf/wd" 15 | 16 | "github.com/elyby/chrly/dispatcher" 17 | v "github.com/elyby/chrly/version" 18 | ) 19 | 20 | type Emitter interface { 21 | dispatcher.Emitter 22 | } 23 | 24 | func StartServer(server *http.Server, logger slf.Logger) { 25 | logger.Debug("Chrly :v (:c)", wd.StringParam("v", v.Version()), wd.StringParam("c", v.Commit())) 26 | 27 | done := make(chan bool, 1) 28 | go func() { 29 | logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr)) 30 | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 31 | logger.Emergency("Error in main(): :err", wd.ErrParam(err)) 32 | close(done) 33 | } 34 | }() 35 | 36 | go func() { 37 | s := waitForExitSignal() 38 | logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String())) 39 | _ = server.Shutdown(context.Background()) 40 | logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String())) 41 | close(done) 42 | }() 43 | 44 | <-done 45 | } 46 | 47 | func waitForExitSignal() os.Signal { 48 | ch := make(chan os.Signal, 1) 49 | signal.Notify(ch, os.Interrupt, syscall.SIGTERM, os.Kill) 50 | 51 | return <-ch 52 | } 53 | 54 | type loggingResponseWriter struct { 55 | http.ResponseWriter 56 | statusCode int 57 | } 58 | 59 | func (lrw *loggingResponseWriter) WriteHeader(code int) { 60 | lrw.statusCode = code 61 | lrw.ResponseWriter.WriteHeader(code) 62 | } 63 | 64 | func CreateRequestEventsMiddleware(emitter Emitter, prefix string) mux.MiddlewareFunc { 65 | beforeTopic := strings.Join([]string{prefix, "before_request"}, ":") 66 | afterTopic := strings.Join([]string{prefix, "after_request"}, ":") 67 | 68 | return func(handler http.Handler) http.Handler { 69 | return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 70 | emitter.Emit(beforeTopic, req) 71 | 72 | lrw := &loggingResponseWriter{ 73 | ResponseWriter: resp, 74 | statusCode: http.StatusOK, 75 | } 76 | handler.ServeHTTP(lrw, req) 77 | 78 | emitter.Emit(afterTopic, req, lrw.statusCode) 79 | }) 80 | } 81 | } 82 | 83 | type Authenticator interface { 84 | Authenticate(req *http.Request) error 85 | } 86 | 87 | func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc { 88 | return func(handler http.Handler) http.Handler { 89 | return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 90 | err := checker.Authenticate(req) 91 | if err != nil { 92 | apiForbidden(resp, err.Error()) 93 | return 94 | } 95 | 96 | handler.ServeHTTP(resp, req) 97 | }) 98 | } 99 | } 100 | 101 | func NotFoundHandler(response http.ResponseWriter, _ *http.Request) { 102 | data, _ := json.Marshal(map[string]string{ 103 | "status": "404", 104 | "message": "Not Found", 105 | }) 106 | 107 | response.Header().Set("Content-Type", "application/json") 108 | response.WriteHeader(http.StatusNotFound) 109 | _, _ = response.Write(data) 110 | } 111 | 112 | func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) { 113 | resp.WriteHeader(http.StatusBadRequest) 114 | resp.Header().Set("Content-Type", "application/json") 115 | result, _ := json.Marshal(map[string]interface{}{ 116 | "errors": errorsPerField, 117 | }) 118 | _, _ = resp.Write(result) 119 | } 120 | 121 | func apiForbidden(resp http.ResponseWriter, reason string) { 122 | resp.WriteHeader(http.StatusForbidden) 123 | resp.Header().Set("Content-Type", "application/json") 124 | result, _ := json.Marshal(map[string]interface{}{ 125 | "error": reason, 126 | }) 127 | _, _ = resp.Write(result) 128 | } 129 | 130 | func apiNotFound(resp http.ResponseWriter, reason string) { 131 | resp.WriteHeader(http.StatusNotFound) 132 | resp.Header().Set("Content-Type", "application/json") 133 | result, _ := json.Marshal([]interface{}{ 134 | reason, 135 | }) 136 | _, _ = resp.Write(result) 137 | } 138 | -------------------------------------------------------------------------------- /http/jwt_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8" 12 | 13 | func TestJwtAuth_NewToken(t *testing.T) { 14 | t.Run("success", func(t *testing.T) { 15 | jwt := &JwtAuth{Key: []byte("secret")} 16 | token, err := jwt.NewToken(SkinScope) 17 | assert.Nil(t, err) 18 | assert.NotNil(t, token) 19 | }) 20 | 21 | t.Run("key not provided", func(t *testing.T) { 22 | jwt := &JwtAuth{} 23 | token, err := jwt.NewToken(SkinScope) 24 | assert.Error(t, err, "signing key not available") 25 | assert.Nil(t, token) 26 | }) 27 | } 28 | 29 | func TestJwtAuth_Authenticate(t *testing.T) { 30 | t.Run("success", func(t *testing.T) { 31 | emitter := &emitterMock{} 32 | emitter.On("Emit", "authentication:success") 33 | 34 | req := httptest.NewRequest("POST", "http://localhost", nil) 35 | req.Header.Add("Authorization", "Bearer "+jwt) 36 | jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter} 37 | 38 | err := jwt.Authenticate(req) 39 | assert.Nil(t, err) 40 | 41 | emitter.AssertExpectations(t) 42 | }) 43 | 44 | t.Run("request without auth header", func(t *testing.T) { 45 | emitter := &emitterMock{} 46 | emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool { 47 | assert.Error(t, err, "Authentication header not presented") 48 | return true 49 | })) 50 | 51 | req := httptest.NewRequest("POST", "http://localhost", nil) 52 | jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter} 53 | 54 | err := jwt.Authenticate(req) 55 | assert.Error(t, err, "Authentication header not presented") 56 | 57 | emitter.AssertExpectations(t) 58 | }) 59 | 60 | t.Run("no bearer token prefix", func(t *testing.T) { 61 | emitter := &emitterMock{} 62 | emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool { 63 | assert.Error(t, err, "Cannot recognize JWT token in passed value") 64 | return true 65 | })) 66 | 67 | req := httptest.NewRequest("POST", "http://localhost", nil) 68 | req.Header.Add("Authorization", "this is not jwt") 69 | jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter} 70 | 71 | err := jwt.Authenticate(req) 72 | assert.Error(t, err, "Cannot recognize JWT token in passed value") 73 | 74 | emitter.AssertExpectations(t) 75 | }) 76 | 77 | t.Run("bearer token but not jwt", func(t *testing.T) { 78 | emitter := &emitterMock{} 79 | emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool { 80 | assert.Error(t, err, "Cannot parse passed JWT token") 81 | return true 82 | })) 83 | 84 | req := httptest.NewRequest("POST", "http://localhost", nil) 85 | req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt") 86 | jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter} 87 | 88 | err := jwt.Authenticate(req) 89 | assert.Error(t, err, "Cannot parse passed JWT token") 90 | 91 | emitter.AssertExpectations(t) 92 | }) 93 | 94 | t.Run("when secret is not set", func(t *testing.T) { 95 | emitter := &emitterMock{} 96 | emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool { 97 | assert.Error(t, err, "Signing key not set") 98 | return true 99 | })) 100 | 101 | req := httptest.NewRequest("POST", "http://localhost", nil) 102 | req.Header.Add("Authorization", "Bearer "+jwt) 103 | jwt := &JwtAuth{Emitter: emitter} 104 | 105 | err := jwt.Authenticate(req) 106 | assert.Error(t, err, "Signing key not set") 107 | 108 | emitter.AssertExpectations(t) 109 | }) 110 | 111 | t.Run("invalid signature", func(t *testing.T) { 112 | emitter := &emitterMock{} 113 | emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool { 114 | assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired") 115 | return true 116 | })) 117 | 118 | req := httptest.NewRequest("POST", "http://localhost", nil) 119 | req.Header.Add("Authorization", "Bearer "+jwt) 120 | jwt := &JwtAuth{Key: []byte("this is another secret"), Emitter: emitter} 121 | 122 | err := jwt.Authenticate(req) 123 | assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired") 124 | 125 | emitter.AssertExpectations(t) 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /http/uuids_worker_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/mock" 11 | "github.com/stretchr/testify/suite" 12 | 13 | "github.com/elyby/chrly/api/mojang" 14 | ) 15 | 16 | /*************** 17 | * Setup mocks * 18 | ***************/ 19 | 20 | type uuidsProviderMock struct { 21 | mock.Mock 22 | } 23 | 24 | func (m *uuidsProviderMock) GetUuid(username string) (*mojang.ProfileInfo, error) { 25 | args := m.Called(username) 26 | var result *mojang.ProfileInfo 27 | if casted, ok := args.Get(0).(*mojang.ProfileInfo); ok { 28 | result = casted 29 | } 30 | 31 | return result, args.Error(1) 32 | } 33 | 34 | type uuidsWorkerTestSuite struct { 35 | suite.Suite 36 | 37 | App *UUIDsWorker 38 | 39 | UuidsProvider *uuidsProviderMock 40 | } 41 | 42 | /******************** 43 | * Setup test suite * 44 | ********************/ 45 | 46 | func (suite *uuidsWorkerTestSuite) SetupTest() { 47 | suite.UuidsProvider = &uuidsProviderMock{} 48 | 49 | suite.App = &UUIDsWorker{ 50 | MojangUuidsProvider: suite.UuidsProvider, 51 | } 52 | } 53 | 54 | func (suite *uuidsWorkerTestSuite) TearDownTest() { 55 | suite.UuidsProvider.AssertExpectations(suite.T()) 56 | } 57 | 58 | func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) { 59 | suite.SetupTest() 60 | suite.Run(name, subTest) 61 | suite.TearDownTest() 62 | } 63 | 64 | /************* 65 | * Run tests * 66 | *************/ 67 | 68 | func TestUUIDsWorker(t *testing.T) { 69 | suite.Run(t, new(uuidsWorkerTestSuite)) 70 | } 71 | 72 | type uuidsWorkerTestCase struct { 73 | Name string 74 | BeforeTest func(suite *uuidsWorkerTestSuite) 75 | AfterTest func(suite *uuidsWorkerTestSuite, response *http.Response) 76 | } 77 | 78 | /************************ 79 | * Get UUID tests cases * 80 | ************************/ 81 | 82 | var getUuidTestsCases = []*uuidsWorkerTestCase{ 83 | { 84 | Name: "Success provider response", 85 | BeforeTest: func(suite *uuidsWorkerTestSuite) { 86 | suite.UuidsProvider.On("GetUuid", "mock_username").Return(&mojang.ProfileInfo{ 87 | Id: "0fcc38620f1845f3a54e1b523c1bd1c7", 88 | Name: "mock_username", 89 | }, nil) 90 | }, 91 | AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) { 92 | suite.Equal(200, response.StatusCode) 93 | suite.Equal("application/json", response.Header.Get("Content-Type")) 94 | body, _ := ioutil.ReadAll(response.Body) 95 | suite.JSONEq(`{ 96 | "id": "0fcc38620f1845f3a54e1b523c1bd1c7", 97 | "name": "mock_username" 98 | }`, string(body)) 99 | }, 100 | }, 101 | { 102 | Name: "Receive empty response from UUIDs provider", 103 | BeforeTest: func(suite *uuidsWorkerTestSuite) { 104 | suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, nil) 105 | }, 106 | AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) { 107 | suite.Equal(204, response.StatusCode) 108 | body, _ := ioutil.ReadAll(response.Body) 109 | suite.Assert().Empty(body) 110 | }, 111 | }, 112 | { 113 | Name: "Receive error from UUIDs provider", 114 | BeforeTest: func(suite *uuidsWorkerTestSuite) { 115 | err := errors.New("this is an error") 116 | suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err) 117 | }, 118 | AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) { 119 | suite.Equal(500, response.StatusCode) 120 | suite.Equal("application/json", response.Header.Get("Content-Type")) 121 | body, _ := ioutil.ReadAll(response.Body) 122 | suite.JSONEq(`{ 123 | "provider": "this is an error" 124 | }`, string(body)) 125 | }, 126 | }, 127 | { 128 | Name: "Receive Too Many Requests from UUIDs provider", 129 | BeforeTest: func(suite *uuidsWorkerTestSuite) { 130 | err := &mojang.TooManyRequestsError{} 131 | suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err) 132 | }, 133 | AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) { 134 | suite.Equal(429, response.StatusCode) 135 | body, _ := ioutil.ReadAll(response.Body) 136 | suite.Empty(body) 137 | }, 138 | }, 139 | } 140 | 141 | func (suite *uuidsWorkerTestSuite) TestGetUUID() { 142 | for _, testCase := range getUuidTestsCases { 143 | suite.RunSubTest(testCase.Name, func() { 144 | testCase.BeforeTest(suite) 145 | 146 | req := httptest.NewRequest("GET", "http://chrly/mojang-uuid/mock_username", nil) 147 | w := httptest.NewRecorder() 148 | 149 | suite.App.Handler().ServeHTTP(w, req) 150 | 151 | testCase.AfterTest(suite, w.Result()) 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /api/mojang/textures_test.go: -------------------------------------------------------------------------------- 1 | package mojang 2 | 3 | import ( 4 | testify "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | type texturesTestCase struct { 9 | Name string 10 | Encoded string 11 | Decoded *TexturesProp 12 | } 13 | 14 | var texturesTestCases = []*texturesTestCase{ 15 | { 16 | Name: "property without textures", 17 | Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319", 18 | Decoded: &TexturesProp{ 19 | ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9", 20 | ProfileName: "ErickSkrauch", 21 | Timestamp: int64(1555856010494), 22 | Textures: &TexturesResponse{}, 23 | }, 24 | }, 25 | { 26 | Name: "property with classic skin textures", 27 | Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=", 28 | Decoded: &TexturesProp{ 29 | ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9", 30 | ProfileName: "ErickSkrauch", 31 | Timestamp: int64(1555856307412), 32 | Textures: &TexturesResponse{ 33 | Skin: &SkinTexturesResponse{ 34 | Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e", 35 | }, 36 | }, 37 | }, 38 | }, 39 | { 40 | Name: "property with alex skin textures", 41 | Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19", 42 | Decoded: &TexturesProp{ 43 | ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9", 44 | ProfileName: "ErickSkrauch", 45 | Timestamp: int64(1555856494791), 46 | Textures: &TexturesResponse{ 47 | Skin: &SkinTexturesResponse{ 48 | Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859", 49 | Metadata: &SkinTexturesMetadata{ 50 | Model: "slim", 51 | }, 52 | }, 53 | }, 54 | }, 55 | }, 56 | { 57 | Name: "property with skin and cape textures", 58 | Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=", 59 | Decoded: &TexturesProp{ 60 | ProfileID: "d90b68bc81724329a047f1186dcd4336", 61 | ProfileName: "akronman1", 62 | Timestamp: int64(1555857675335), 63 | Textures: &TexturesResponse{ 64 | Skin: &SkinTexturesResponse{ 65 | Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7", 66 | }, 67 | Cape: &CapeTexturesResponse{ 68 | Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf", 69 | }, 70 | }, 71 | }, 72 | }, 73 | } 74 | 75 | func TestDecodeTextures(t *testing.T) { 76 | for _, testCase := range texturesTestCases { 77 | t.Run("decode "+testCase.Name, func(t *testing.T) { 78 | assert := testify.New(t) 79 | 80 | result, err := DecodeTextures(testCase.Encoded) 81 | assert.Nil(err) 82 | assert.Equal(testCase.Decoded, result) 83 | }) 84 | } 85 | 86 | t.Run("should return error if invalid base64 passed", func(t *testing.T) { 87 | assert := testify.New(t) 88 | 89 | result, err := DecodeTextures("invalid base64") 90 | assert.Error(err) 91 | assert.Nil(result) 92 | }) 93 | 94 | t.Run("should return error if invalid json found inside base64", func(t *testing.T) { 95 | assert := testify.New(t) 96 | 97 | result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json" 98 | assert.Error(err) 99 | assert.Nil(result) 100 | }) 101 | } 102 | 103 | func TestEncodeTextures(t *testing.T) { 104 | for _, testCase := range texturesTestCases { 105 | t.Run("encode "+testCase.Name, func(t *testing.T) { 106 | assert := testify.New(t) 107 | 108 | result := EncodeTextures(testCase.Decoded) 109 | assert.Equal(testCase.Encoded, result) 110 | }) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /di/handlers.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/defval/di" 9 | "github.com/etherlabsio/healthcheck/v2" 10 | "github.com/gorilla/mux" 11 | "github.com/spf13/viper" 12 | 13 | . "github.com/elyby/chrly/http" 14 | "github.com/elyby/chrly/mojangtextures" 15 | ) 16 | 17 | var handlers = di.Options( 18 | di.Provide(newHandlerFactory, di.As(new(http.Handler))), 19 | di.Provide(newSkinsystemHandler, di.WithName("skinsystem")), 20 | di.Provide(newApiHandler, di.WithName("api")), 21 | di.Provide(newUUIDsWorkerHandler, di.WithName("worker")), 22 | ) 23 | 24 | func newHandlerFactory( 25 | container *di.Container, 26 | config *viper.Viper, 27 | emitter Emitter, 28 | ) (*mux.Router, error) { 29 | enabledModules := config.GetStringSlice("modules") 30 | 31 | // gorilla.mux has no native way to combine multiple routers. 32 | // The hack used later in the code works for prefixes in addresses, but leads to misbehavior 33 | // if you set an empty prefix. Since the main application should be mounted at the root prefix, 34 | // we use it as the base router 35 | var router *mux.Router 36 | if hasValue(enabledModules, "skinsystem") { 37 | if err := container.Resolve(&router, di.Name("skinsystem")); err != nil { 38 | return nil, err 39 | } 40 | } else { 41 | router = mux.NewRouter() 42 | } 43 | 44 | router.StrictSlash(true) 45 | requestEventsMiddleware := CreateRequestEventsMiddleware(emitter, "skinsystem") 46 | router.Use(requestEventsMiddleware) 47 | // NotFoundHandler doesn't call for registered middlewares, so we must wrap it manually. 48 | // See https://github.com/gorilla/mux/issues/416#issuecomment-600079279 49 | router.NotFoundHandler = requestEventsMiddleware(http.HandlerFunc(NotFoundHandler)) 50 | 51 | // Enable the worker module before api to allow gorilla.mux to correctly find the target router 52 | // as it uses the first matching and /api overrides the more accurate /api/worker 53 | if hasValue(enabledModules, "worker") { 54 | var workerRouter *mux.Router 55 | if err := container.Resolve(&workerRouter, di.Name("worker")); err != nil { 56 | return nil, err 57 | } 58 | 59 | mount(router, "/api/worker", workerRouter) 60 | } 61 | 62 | if hasValue(enabledModules, "api") { 63 | var apiRouter *mux.Router 64 | if err := container.Resolve(&apiRouter, di.Name("api")); err != nil { 65 | return nil, err 66 | } 67 | 68 | var authenticator Authenticator 69 | if err := container.Resolve(&authenticator); err != nil { 70 | return nil, err 71 | } 72 | 73 | apiRouter.Use(CreateAuthenticationMiddleware(authenticator)) 74 | 75 | mount(router, "/api", apiRouter) 76 | } 77 | 78 | err := container.Invoke(enableReporters) 79 | if err != nil && !errors.Is(err, di.ErrTypeNotExists) { 80 | return nil, err 81 | } 82 | 83 | // Resolve health checkers last, because all the services required by the application 84 | // must first be initialized and each of them can publish its own checkers 85 | var healthCheckers []*namedHealthChecker 86 | if has, _ := container.Has(&healthCheckers); has { 87 | if err := container.Resolve(&healthCheckers); err != nil { 88 | return nil, err 89 | } 90 | 91 | checkersOptions := make([]healthcheck.Option, len(healthCheckers)) 92 | for i, checker := range healthCheckers { 93 | checkersOptions[i] = healthcheck.WithChecker(checker.Name, checker.Checker) 94 | } 95 | 96 | router.Handle("/healthcheck", healthcheck.Handler(checkersOptions...)).Methods("GET") 97 | } 98 | 99 | return router, nil 100 | } 101 | 102 | func newSkinsystemHandler( 103 | config *viper.Viper, 104 | emitter Emitter, 105 | skinsRepository SkinsRepository, 106 | capesRepository CapesRepository, 107 | mojangTexturesProvider MojangTexturesProvider, 108 | texturesSigner TexturesSigner, 109 | ) (*mux.Router, error) { 110 | config.SetDefault("textures.extra_param_name", "chrly") 111 | config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?") 112 | 113 | app, err := NewSkinsystem( 114 | emitter, 115 | skinsRepository, 116 | capesRepository, 117 | mojangTexturesProvider, 118 | texturesSigner, 119 | config.GetString("textures.extra_param_name"), 120 | config.GetString("textures.extra_param_value"), 121 | ) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | return app.Handler(), nil 127 | } 128 | 129 | func newApiHandler(skinsRepository SkinsRepository) *mux.Router { 130 | return (&Api{ 131 | SkinsRepo: skinsRepository, 132 | }).Handler() 133 | } 134 | 135 | func newUUIDsWorkerHandler(mojangUUIDsProvider *mojangtextures.BatchUuidsProvider) *mux.Router { 136 | return (&UUIDsWorker{ 137 | MojangUuidsProvider: mojangUUIDsProvider, 138 | }).Handler() 139 | } 140 | 141 | func hasValue(slice []string, needle string) bool { 142 | for _, value := range slice { 143 | if value == needle { 144 | return true 145 | } 146 | } 147 | 148 | return false 149 | } 150 | 151 | func mount(router *mux.Router, path string, handler http.Handler) { 152 | router.PathPrefix(path).Handler( 153 | http.StripPrefix( 154 | strings.TrimSuffix(path, "/"), 155 | handler, 156 | ), 157 | ) 158 | } 159 | 160 | type namedHealthChecker struct { 161 | Name string 162 | Checker healthcheck.Checker 163 | } 164 | -------------------------------------------------------------------------------- /mojangtextures/remote_api_uuids_provider_test.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | . "net/url" 7 | "testing" 8 | 9 | "github.com/h2non/gock" 10 | 11 | "github.com/stretchr/testify/mock" 12 | "github.com/stretchr/testify/suite" 13 | ) 14 | 15 | type remoteApiUuidsProviderTestSuite struct { 16 | suite.Suite 17 | 18 | Provider *RemoteApiUuidsProvider 19 | Emitter *mockEmitter 20 | } 21 | 22 | func (suite *remoteApiUuidsProviderTestSuite) SetupSuite() { 23 | client := &http.Client{} 24 | gock.InterceptClient(client) 25 | 26 | HttpClient = client 27 | } 28 | 29 | func (suite *remoteApiUuidsProviderTestSuite) SetupTest() { 30 | suite.Emitter = &mockEmitter{} 31 | suite.Provider = &RemoteApiUuidsProvider{ 32 | Emitter: suite.Emitter, 33 | } 34 | } 35 | 36 | func (suite *remoteApiUuidsProviderTestSuite) TearDownTest() { 37 | suite.Emitter.AssertExpectations(suite.T()) 38 | gock.Off() 39 | } 40 | 41 | func TestRemoteApiUuidsProvider(t *testing.T) { 42 | suite.Run(t, new(remoteApiUuidsProviderTestSuite)) 43 | } 44 | 45 | func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForValidUsername() { 46 | suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once() 47 | suite.Emitter.On("Emit", 48 | "mojang_textures:remote_api_uuids_provider:after_request", 49 | mock.AnythingOfType("*http.Response"), 50 | nil, 51 | ).Once() 52 | 53 | gock.New("http://example.com"). 54 | Get("/subpath/username"). 55 | Reply(200). 56 | JSON(map[string]interface{}{ 57 | "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 58 | "name": "username", 59 | }) 60 | 61 | suite.Provider.Url = shouldParseUrl("http://example.com/subpath") 62 | result, err := suite.Provider.GetUuid("username") 63 | 64 | assert := suite.Assert() 65 | if assert.NoError(err) { 66 | assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", result.Id) 67 | assert.Equal("username", result.Name) 68 | assert.False(result.IsLegacy) 69 | assert.False(result.IsDemo) 70 | } 71 | } 72 | 73 | func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotExistsUsername() { 74 | suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once() 75 | suite.Emitter.On("Emit", 76 | "mojang_textures:remote_api_uuids_provider:after_request", 77 | mock.AnythingOfType("*http.Response"), 78 | nil, 79 | ).Once() 80 | 81 | gock.New("http://example.com"). 82 | Get("/subpath/username"). 83 | Reply(204) 84 | 85 | suite.Provider.Url = shouldParseUrl("http://example.com/subpath") 86 | result, err := suite.Provider.GetUuid("username") 87 | 88 | assert := suite.Assert() 89 | assert.Nil(result) 90 | assert.Nil(err) 91 | } 92 | 93 | func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNon20xResponse() { 94 | suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once() 95 | suite.Emitter.On("Emit", 96 | "mojang_textures:remote_api_uuids_provider:after_request", 97 | mock.AnythingOfType("*http.Response"), 98 | nil, 99 | ).Once() 100 | 101 | gock.New("http://example.com"). 102 | Get("/subpath/username"). 103 | Reply(504). 104 | BodyString("504 Gateway Timeout") 105 | 106 | suite.Provider.Url = shouldParseUrl("http://example.com/subpath") 107 | result, err := suite.Provider.GetUuid("username") 108 | 109 | assert := suite.Assert() 110 | assert.Nil(result) 111 | assert.EqualError(err, "Unexpected remote api response") 112 | } 113 | 114 | func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotSuccessRequest() { 115 | suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once() 116 | suite.Emitter.On("Emit", 117 | "mojang_textures:remote_api_uuids_provider:after_request", 118 | mock.AnythingOfType("*http.Response"), 119 | mock.AnythingOfType("*url.Error"), 120 | ).Once() 121 | 122 | expectedError := &net.OpError{Op: "dial"} 123 | 124 | gock.New("http://example.com"). 125 | Get("/subpath/username"). 126 | ReplyError(expectedError) 127 | 128 | suite.Provider.Url = shouldParseUrl("http://example.com/subpath") 129 | result, err := suite.Provider.GetUuid("username") 130 | 131 | assert := suite.Assert() 132 | assert.Nil(result) 133 | if assert.Error(err) { 134 | assert.IsType(&Error{}, err) 135 | casterErr, _ := err.(*Error) 136 | assert.Equal(expectedError, casterErr.Err) 137 | } 138 | } 139 | 140 | func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForInvalidSuccessResponse() { 141 | suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once() 142 | suite.Emitter.On("Emit", 143 | "mojang_textures:remote_api_uuids_provider:after_request", 144 | mock.AnythingOfType("*http.Response"), 145 | nil, 146 | ).Once() 147 | 148 | gock.New("http://example.com"). 149 | Get("/subpath/username"). 150 | Reply(200). 151 | BodyString("completely not json") 152 | 153 | suite.Provider.Url = shouldParseUrl("http://example.com/subpath") 154 | result, err := suite.Provider.GetUuid("username") 155 | 156 | assert := suite.Assert() 157 | assert.Nil(result) 158 | assert.Error(err) 159 | } 160 | 161 | func shouldParseUrl(rawUrl string) URL { 162 | url, err := Parse(rawUrl) 163 | if err != nil { 164 | panic(err) 165 | } 166 | 167 | return *url 168 | } 169 | -------------------------------------------------------------------------------- /eventsubscribers/health_checkers_test.go: -------------------------------------------------------------------------------- 1 | package eventsubscribers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/mock" 11 | 12 | "github.com/elyby/chrly/api/mojang" 13 | "github.com/elyby/chrly/dispatcher" 14 | ) 15 | 16 | type pingableMock struct { 17 | mock.Mock 18 | } 19 | 20 | func (p *pingableMock) Ping() error { 21 | args := p.Called() 22 | return args.Error(0) 23 | } 24 | 25 | func TestDatabaseChecker(t *testing.T) { 26 | t.Run("no error", func(t *testing.T) { 27 | p := &pingableMock{} 28 | p.On("Ping").Return(nil) 29 | checker := DatabaseChecker(p) 30 | assert.Nil(t, checker(context.Background())) 31 | }) 32 | 33 | t.Run("with error", func(t *testing.T) { 34 | err := errors.New("mock error") 35 | p := &pingableMock{} 36 | p.On("Ping").Return(err) 37 | checker := DatabaseChecker(p) 38 | assert.Equal(t, err, checker(context.Background())) 39 | }) 40 | 41 | t.Run("context timeout", func(t *testing.T) { 42 | p := &pingableMock{} 43 | waitChan := make(chan time.Time, 1) 44 | p.On("Ping").WaitUntil(waitChan).Return(nil) 45 | 46 | ctx, cancel := context.WithTimeout(context.Background(), 0) 47 | defer cancel() 48 | 49 | checker := DatabaseChecker(p) 50 | assert.Errorf(t, checker(ctx), "check timeout") 51 | close(waitChan) 52 | }) 53 | } 54 | 55 | func TestMojangBatchUuidsProviderChecker(t *testing.T) { 56 | t.Run("empty state", func(t *testing.T) { 57 | d := dispatcher.New() 58 | checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond) 59 | assert.Nil(t, checker(context.Background())) 60 | }) 61 | 62 | t.Run("when no error occurred", func(t *testing.T) { 63 | d := dispatcher.New() 64 | checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond) 65 | d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil) 66 | assert.Nil(t, checker(context.Background())) 67 | }) 68 | 69 | t.Run("when error occurred", func(t *testing.T) { 70 | d := dispatcher.New() 71 | checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond) 72 | err := errors.New("some error occurred") 73 | d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err) 74 | assert.Equal(t, err, checker(context.Background())) 75 | }) 76 | 77 | t.Run("should reset value after passed duration", func(t *testing.T) { 78 | d := dispatcher.New() 79 | checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond) 80 | err := errors.New("some error occurred") 81 | d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err) 82 | assert.Equal(t, err, checker(context.Background())) 83 | time.Sleep(40 * time.Millisecond) 84 | assert.Nil(t, checker(context.Background())) 85 | }) 86 | } 87 | 88 | func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) { 89 | t.Run("empty state", func(t *testing.T) { 90 | d := dispatcher.New() 91 | checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10) 92 | assert.Nil(t, checker(context.Background())) 93 | }) 94 | 95 | t.Run("less than allowed limit", func(t *testing.T) { 96 | d := dispatcher.New() 97 | checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10) 98 | d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9) 99 | assert.Nil(t, checker(context.Background())) 100 | }) 101 | 102 | t.Run("greater than allowed limit", func(t *testing.T) { 103 | d := dispatcher.New() 104 | checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10) 105 | d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10) 106 | checkResult := checker(context.Background()) 107 | if assert.Error(t, checkResult) { 108 | assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error()) 109 | } 110 | }) 111 | } 112 | 113 | func TestMojangApiTexturesProviderResponseChecker(t *testing.T) { 114 | t.Run("empty state", func(t *testing.T) { 115 | d := dispatcher.New() 116 | checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond) 117 | assert.Nil(t, checker(context.Background())) 118 | }) 119 | 120 | t.Run("when no error occurred", func(t *testing.T) { 121 | d := dispatcher.New() 122 | checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond) 123 | d.Emit("mojang_textures:mojang_api_textures_provider:after_request", 124 | "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 125 | &mojang.SignedTexturesResponse{}, 126 | nil, 127 | ) 128 | assert.Nil(t, checker(context.Background())) 129 | }) 130 | 131 | t.Run("when error occurred", func(t *testing.T) { 132 | d := dispatcher.New() 133 | checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond) 134 | err := errors.New("some error occurred") 135 | d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err) 136 | assert.Equal(t, err, checker(context.Background())) 137 | }) 138 | 139 | t.Run("should reset value after passed duration", func(t *testing.T) { 140 | d := dispatcher.New() 141 | checker := MojangApiTexturesProviderResponseChecker(d, 20*time.Millisecond) 142 | err := errors.New("some error occurred") 143 | d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err) 144 | assert.Equal(t, err, checker(context.Background())) 145 | time.Sleep(40 * time.Millisecond) 146 | assert.Nil(t, checker(context.Background())) 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /mojangtextures/in_memory_textures_storage_test.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | assert "github.com/stretchr/testify/require" 8 | 9 | "github.com/elyby/chrly/api/mojang" 10 | ) 11 | 12 | var texturesWithSkin = &mojang.SignedTexturesResponse{ 13 | Id: "dead24f9a4fa4877b7b04c8c6c72bb46", 14 | Name: "mock", 15 | Props: []*mojang.Property{ 16 | { 17 | Name: "textures", 18 | Value: mojang.EncodeTextures(&mojang.TexturesProp{ 19 | Timestamp: time.Now().UnixNano() / 10e5, 20 | ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46", 21 | ProfileName: "mock", 22 | Textures: &mojang.TexturesResponse{ 23 | Skin: &mojang.SkinTexturesResponse{ 24 | Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75", 25 | }, 26 | }, 27 | }), 28 | }, 29 | }, 30 | } 31 | var texturesWithoutSkin = &mojang.SignedTexturesResponse{ 32 | Id: "dead24f9a4fa4877b7b04c8c6c72bb46", 33 | Name: "mock", 34 | Props: []*mojang.Property{ 35 | { 36 | Name: "textures", 37 | Value: mojang.EncodeTextures(&mojang.TexturesProp{ 38 | Timestamp: time.Now().UnixNano() / 10e5, 39 | ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46", 40 | ProfileName: "mock", 41 | Textures: &mojang.TexturesResponse{}, 42 | }), 43 | }, 44 | }, 45 | } 46 | 47 | func TestInMemoryTexturesStorage_GetTextures(t *testing.T) { 48 | t.Run("should return nil, nil when textures are unavailable", func(t *testing.T) { 49 | storage := NewInMemoryTexturesStorage() 50 | result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579") 51 | 52 | assert.Nil(t, result) 53 | assert.Nil(t, err) 54 | }) 55 | 56 | t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) { 57 | storage := NewInMemoryTexturesStorage() 58 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin) 59 | result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") 60 | 61 | assert.Equal(t, texturesWithSkin, result) 62 | assert.Nil(t, err) 63 | }) 64 | 65 | t.Run("should return nil, nil when textures are exists, but cache duration is expired", func(t *testing.T) { 66 | storage := NewInMemoryTexturesStorage() 67 | storage.Duration = 10 * time.Millisecond 68 | storage.GCPeriod = time.Minute 69 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin) 70 | 71 | time.Sleep(storage.Duration * 2) 72 | 73 | result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") 74 | 75 | assert.Nil(t, result) 76 | assert.Nil(t, err) 77 | }) 78 | } 79 | 80 | func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) { 81 | t.Run("store textures for previously not existed uuid", func(t *testing.T) { 82 | storage := NewInMemoryTexturesStorage() 83 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin) 84 | result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") 85 | 86 | assert.Equal(t, texturesWithSkin, result) 87 | assert.Nil(t, err) 88 | }) 89 | 90 | t.Run("override already existed textures for uuid", func(t *testing.T) { 91 | storage := NewInMemoryTexturesStorage() 92 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin) 93 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin) 94 | result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") 95 | 96 | assert.NotEqual(t, texturesWithoutSkin, result) 97 | assert.Equal(t, texturesWithSkin, result) 98 | assert.Nil(t, err) 99 | }) 100 | 101 | t.Run("store textures with empty properties", func(t *testing.T) { 102 | texturesWithEmptyProps := &mojang.SignedTexturesResponse{ 103 | Id: "dead24f9a4fa4877b7b04c8c6c72bb46", 104 | Name: "mock", 105 | Props: []*mojang.Property{}, 106 | } 107 | 108 | storage := NewInMemoryTexturesStorage() 109 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithEmptyProps) 110 | result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") 111 | 112 | assert.Exactly(t, texturesWithEmptyProps, result) 113 | assert.Nil(t, err) 114 | }) 115 | 116 | t.Run("store nil textures", func(t *testing.T) { 117 | storage := NewInMemoryTexturesStorage() 118 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil) 119 | result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") 120 | 121 | assert.Nil(t, result) 122 | assert.Nil(t, err) 123 | }) 124 | } 125 | 126 | func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) { 127 | storage := NewInMemoryTexturesStorage() 128 | defer storage.Stop() 129 | storage.GCPeriod = 10 * time.Millisecond 130 | storage.Duration = 9 * time.Millisecond 131 | 132 | textures1 := &mojang.SignedTexturesResponse{ 133 | Id: "dead24f9a4fa4877b7b04c8c6c72bb46", 134 | Name: "mock1", 135 | Props: []*mojang.Property{}, 136 | } 137 | textures2 := &mojang.SignedTexturesResponse{ 138 | Id: "b5d58475007d4f9e9ddd1403e2497579", 139 | Name: "mock2", 140 | Props: []*mojang.Property{}, 141 | } 142 | 143 | storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1) 144 | // Store another texture a bit later to avoid it removing by GC after the first iteration 145 | time.Sleep(2 * time.Millisecond) 146 | storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2) 147 | 148 | storage.lock.RLock() 149 | assert.Len(t, storage.data, 2, "the GC period has not yet reached") 150 | storage.lock.RUnlock() 151 | 152 | time.Sleep(storage.GCPeriod) // Let it perform the first GC iteration 153 | 154 | storage.lock.RLock() 155 | assert.Len(t, storage.data, 1, "the first texture should be cleaned by GC") 156 | assert.Contains(t, storage.data, "b5d58475007d4f9e9ddd1403e2497579") 157 | storage.lock.RUnlock() 158 | 159 | time.Sleep(storage.GCPeriod) // Let another iteration happen 160 | 161 | storage.lock.RLock() 162 | assert.Len(t, storage.data, 0) 163 | storage.lock.RUnlock() 164 | } 165 | -------------------------------------------------------------------------------- /mojangtextures/mojang_textures.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "sync" 7 | 8 | "github.com/elyby/chrly/api/mojang" 9 | "github.com/elyby/chrly/dispatcher" 10 | ) 11 | 12 | type broadcastResult struct { 13 | textures *mojang.SignedTexturesResponse 14 | error error 15 | } 16 | 17 | type broadcaster struct { 18 | lock sync.Mutex 19 | listeners map[string][]chan *broadcastResult 20 | } 21 | 22 | func createBroadcaster() *broadcaster { 23 | return &broadcaster{ 24 | listeners: make(map[string][]chan *broadcastResult), 25 | } 26 | } 27 | 28 | // Returns a boolean value, which will be true if the passed username didn't exist before 29 | func (c *broadcaster) AddListener(username string, resultChan chan *broadcastResult) bool { 30 | c.lock.Lock() 31 | defer c.lock.Unlock() 32 | 33 | val, alreadyHasSource := c.listeners[username] 34 | if alreadyHasSource { 35 | c.listeners[username] = append(val, resultChan) 36 | return false 37 | } 38 | 39 | c.listeners[username] = []chan *broadcastResult{resultChan} 40 | 41 | return true 42 | } 43 | 44 | func (c *broadcaster) BroadcastAndRemove(username string, result *broadcastResult) { 45 | c.lock.Lock() 46 | defer c.lock.Unlock() 47 | 48 | val, ok := c.listeners[username] 49 | if !ok { 50 | return 51 | } 52 | 53 | for _, channel := range val { 54 | go func(channel chan *broadcastResult) { 55 | channel <- result 56 | close(channel) 57 | }(channel) 58 | } 59 | 60 | delete(c.listeners, username) 61 | } 62 | 63 | // https://help.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV 64 | var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{3,16}$`) 65 | 66 | type UUIDsProvider interface { 67 | GetUuid(username string) (*mojang.ProfileInfo, error) 68 | } 69 | 70 | type TexturesProvider interface { 71 | GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) 72 | } 73 | 74 | type Emitter interface { 75 | dispatcher.Emitter 76 | } 77 | 78 | type Provider struct { 79 | Emitter 80 | UUIDsProvider 81 | TexturesProvider 82 | Storage 83 | 84 | onFirstCall sync.Once 85 | *broadcaster 86 | } 87 | 88 | func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) { 89 | ctx.onFirstCall.Do(func() { 90 | ctx.broadcaster = createBroadcaster() 91 | }) 92 | 93 | if !allowedUsernamesRegex.MatchString(username) { 94 | return nil, nil 95 | } 96 | 97 | username = strings.ToLower(username) 98 | ctx.Emit("mojang_textures:call", username) 99 | 100 | uuid, found, err := ctx.getUuidFromCache(username) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | if found && uuid == "" { 106 | return nil, nil 107 | } 108 | 109 | if uuid != "" { 110 | textures, err := ctx.getTexturesFromCache(uuid) 111 | if err == nil && textures != nil { 112 | return textures, nil 113 | } 114 | } 115 | 116 | resultChan := make(chan *broadcastResult) 117 | isFirstListener := ctx.broadcaster.AddListener(username, resultChan) 118 | if isFirstListener { 119 | go ctx.getResultAndBroadcast(username, uuid) 120 | } else { 121 | ctx.Emit("mojang_textures:already_processing", username) 122 | } 123 | 124 | result := <-resultChan 125 | 126 | return result.textures, result.error 127 | } 128 | 129 | func (ctx *Provider) getResultAndBroadcast(username string, uuid string) { 130 | ctx.Emit("mojang_textures:before_result", username, uuid) 131 | result := ctx.getResult(username, uuid) 132 | ctx.Emit("mojang_textures:after_result", username, result.textures, result.error) 133 | 134 | ctx.broadcaster.BroadcastAndRemove(username, result) 135 | } 136 | 137 | func (ctx *Provider) getResult(username string, cachedUuid string) *broadcastResult { 138 | uuid := cachedUuid 139 | if uuid == "" { 140 | profile, err := ctx.getUuid(username) 141 | if err != nil { 142 | return &broadcastResult{nil, err} 143 | } 144 | 145 | uuid = "" 146 | if profile != nil { 147 | uuid = profile.Id 148 | } 149 | 150 | _ = ctx.Storage.StoreUuid(username, uuid) 151 | 152 | if uuid == "" { 153 | return &broadcastResult{nil, nil} 154 | } 155 | } 156 | 157 | textures, err := ctx.getTextures(uuid) 158 | if err != nil { 159 | // Previously cached UUIDs may disappear 160 | // In this case we must invalidate UUID cache for given username 161 | if _, ok := err.(*mojang.EmptyResponse); ok && cachedUuid != "" { 162 | return ctx.getResult(username, "") 163 | } 164 | 165 | return &broadcastResult{nil, err} 166 | } 167 | 168 | // Mojang can respond with an error, but it will still count as a hit, 169 | // therefore store the result even if textures is nil to prevent 429 error 170 | ctx.Storage.StoreTextures(uuid, textures) 171 | 172 | return &broadcastResult{textures, nil} 173 | } 174 | 175 | func (ctx *Provider) getUuidFromCache(username string) (string, bool, error) { 176 | ctx.Emit("mojang_textures:usernames:before_cache", username) 177 | uuid, found, err := ctx.Storage.GetUuid(username) 178 | ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, found, err) 179 | 180 | return uuid, found, err 181 | } 182 | 183 | func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) { 184 | ctx.Emit("mojang_textures:textures:before_cache", uuid) 185 | textures, err := ctx.Storage.GetTextures(uuid) 186 | ctx.Emit("mojang_textures:textures:after_cache", uuid, textures, err) 187 | 188 | return textures, err 189 | } 190 | 191 | func (ctx *Provider) getUuid(username string) (*mojang.ProfileInfo, error) { 192 | ctx.Emit("mojang_textures:usernames:before_call", username) 193 | profile, err := ctx.UUIDsProvider.GetUuid(username) 194 | ctx.Emit("mojang_textures:usernames:after_call", username, profile, err) 195 | 196 | return profile, err 197 | } 198 | 199 | func (ctx *Provider) getTextures(uuid string) (*mojang.SignedTexturesResponse, error) { 200 | ctx.Emit("mojang_textures:textures:before_call", uuid) 201 | textures, err := ctx.TexturesProvider.GetTextures(uuid) 202 | ctx.Emit("mojang_textures:textures:after_call", uuid, textures, err) 203 | 204 | return textures, err 205 | } 206 | -------------------------------------------------------------------------------- /mojangtextures/batch_uuids_provider.go: -------------------------------------------------------------------------------- 1 | package mojangtextures 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/elyby/chrly/api/mojang" 10 | ) 11 | 12 | type jobResult struct { 13 | Profile *mojang.ProfileInfo 14 | Error error 15 | } 16 | 17 | type job struct { 18 | Username string 19 | RespondChan chan *jobResult 20 | } 21 | 22 | type jobsQueue struct { 23 | lock sync.Mutex 24 | items []*job 25 | } 26 | 27 | func newJobsQueue() *jobsQueue { 28 | return &jobsQueue{ 29 | items: []*job{}, 30 | } 31 | } 32 | 33 | func (s *jobsQueue) Enqueue(job *job) int { 34 | s.lock.Lock() 35 | defer s.lock.Unlock() 36 | 37 | s.items = append(s.items, job) 38 | 39 | return len(s.items) 40 | } 41 | 42 | func (s *jobsQueue) Dequeue(n int) ([]*job, int) { 43 | s.lock.Lock() 44 | defer s.lock.Unlock() 45 | 46 | l := len(s.items) 47 | if n > l { 48 | n = l 49 | } 50 | 51 | items := s.items[0:n] 52 | s.items = s.items[n:l] 53 | 54 | return items, l - n 55 | } 56 | 57 | var usernamesToUuids = mojang.UsernamesToUuids 58 | 59 | type JobsIteration struct { 60 | Jobs []*job 61 | Queue int 62 | c chan struct{} 63 | } 64 | 65 | func (j *JobsIteration) Done() { 66 | if j.c != nil { 67 | close(j.c) 68 | } 69 | } 70 | 71 | type BatchUuidsProviderStrategy interface { 72 | Queue(job *job) 73 | GetJobs(abort context.Context) <-chan *JobsIteration 74 | } 75 | 76 | type PeriodicStrategy struct { 77 | Delay time.Duration 78 | Batch int 79 | queue *jobsQueue 80 | done chan struct{} 81 | } 82 | 83 | func NewPeriodicStrategy(delay time.Duration, batch int) *PeriodicStrategy { 84 | return &PeriodicStrategy{ 85 | Delay: delay, 86 | Batch: batch, 87 | queue: newJobsQueue(), 88 | } 89 | } 90 | 91 | func (ctx *PeriodicStrategy) Queue(job *job) { 92 | ctx.queue.Enqueue(job) 93 | } 94 | 95 | func (ctx *PeriodicStrategy) GetJobs(abort context.Context) <-chan *JobsIteration { 96 | ch := make(chan *JobsIteration) 97 | go func() { 98 | for { 99 | select { 100 | case <-abort.Done(): 101 | close(ch) 102 | return 103 | case <-time.After(ctx.Delay): 104 | jobs, queueLen := ctx.queue.Dequeue(ctx.Batch) 105 | jobDoneChan := make(chan struct{}) 106 | ch <- &JobsIteration{jobs, queueLen, jobDoneChan} 107 | <-jobDoneChan 108 | } 109 | } 110 | }() 111 | 112 | return ch 113 | } 114 | 115 | type FullBusStrategy struct { 116 | Delay time.Duration 117 | Batch int 118 | queue *jobsQueue 119 | busIsFull chan bool 120 | } 121 | 122 | func NewFullBusStrategy(delay time.Duration, batch int) *FullBusStrategy { 123 | return &FullBusStrategy{ 124 | Delay: delay, 125 | Batch: batch, 126 | queue: newJobsQueue(), 127 | busIsFull: make(chan bool), 128 | } 129 | } 130 | 131 | func (ctx *FullBusStrategy) Queue(job *job) { 132 | n := ctx.queue.Enqueue(job) 133 | if n%ctx.Batch == 0 { 134 | ctx.busIsFull <- true 135 | } 136 | } 137 | 138 | // Формально, это описание логики водителя маршрутки xD 139 | func (ctx *FullBusStrategy) GetJobs(abort context.Context) <-chan *JobsIteration { 140 | ch := make(chan *JobsIteration) 141 | go func() { 142 | for { 143 | t := time.NewTimer(ctx.Delay) 144 | select { 145 | case <-abort.Done(): 146 | close(ch) 147 | return 148 | case <-t.C: 149 | ctx.sendJobs(ch) 150 | case <-ctx.busIsFull: 151 | t.Stop() 152 | ctx.sendJobs(ch) 153 | } 154 | } 155 | }() 156 | 157 | return ch 158 | } 159 | 160 | func (ctx *FullBusStrategy) sendJobs(ch chan *JobsIteration) { 161 | jobs, queueLen := ctx.queue.Dequeue(ctx.Batch) 162 | ch <- &JobsIteration{jobs, queueLen, nil} 163 | } 164 | 165 | type BatchUuidsProvider struct { 166 | context context.Context 167 | emitter Emitter 168 | strategy BatchUuidsProviderStrategy 169 | onFirstCall sync.Once 170 | } 171 | 172 | func NewBatchUuidsProvider( 173 | context context.Context, 174 | strategy BatchUuidsProviderStrategy, 175 | emitter Emitter, 176 | ) *BatchUuidsProvider { 177 | return &BatchUuidsProvider{ 178 | context: context, 179 | emitter: emitter, 180 | strategy: strategy, 181 | } 182 | } 183 | 184 | func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) { 185 | ctx.onFirstCall.Do(ctx.startQueue) 186 | 187 | resultChan := make(chan *jobResult) 188 | ctx.strategy.Queue(&job{username, resultChan}) 189 | ctx.emitter.Emit("mojang_textures:batch_uuids_provider:queued", username) 190 | 191 | result := <-resultChan 192 | 193 | return result.Profile, result.Error 194 | } 195 | 196 | func (ctx *BatchUuidsProvider) startQueue() { 197 | // This synchronization chan is used to ensure that strategy's jobs provider 198 | // will be initialized before any job will be scheduled 199 | d := make(chan struct{}) 200 | go func() { 201 | jobsChan := ctx.strategy.GetJobs(ctx.context) 202 | close(d) 203 | for { 204 | select { 205 | case <-ctx.context.Done(): 206 | return 207 | case iteration := <-jobsChan: 208 | go func() { 209 | ctx.performRequest(iteration) 210 | iteration.Done() 211 | }() 212 | } 213 | } 214 | }() 215 | 216 | <-d 217 | } 218 | 219 | func (ctx *BatchUuidsProvider) performRequest(iteration *JobsIteration) { 220 | usernames := make([]string, len(iteration.Jobs)) 221 | for i, job := range iteration.Jobs { 222 | usernames[i] = job.Username 223 | } 224 | 225 | ctx.emitter.Emit("mojang_textures:batch_uuids_provider:round", usernames, iteration.Queue) 226 | if len(usernames) == 0 { 227 | return 228 | } 229 | 230 | profiles, err := usernamesToUuids(usernames) 231 | ctx.emitter.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err) 232 | for _, job := range iteration.Jobs { 233 | response := &jobResult{} 234 | if err == nil { 235 | // The profiles in the response aren't ordered, so we must search each username over full array 236 | for _, profile := range profiles { 237 | if strings.EqualFold(job.Username, profile.Name) { 238 | response.Profile = profile 239 | break 240 | } 241 | } 242 | } else { 243 | response.Error = err 244 | } 245 | 246 | job.RespondChan <- response 247 | close(job.RespondChan) 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /http/api.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | 10 | "github.com/gorilla/mux" 11 | "github.com/thedevsaddam/govalidator" 12 | 13 | "github.com/elyby/chrly/model" 14 | ) 15 | 16 | // noinspection GoSnakeCaseUsage 17 | const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" 18 | 19 | var regexUuidAny = regexp.MustCompile(UUID_ANY) 20 | 21 | func init() { 22 | // Add ability to validate any possible uuid form 23 | govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error { 24 | str := value.(string) 25 | if !regexUuidAny.MatchString(str) { 26 | if message == "" { 27 | message = fmt.Sprintf("The %s field must contain valid UUID", field) 28 | } 29 | 30 | return errors.New(message) 31 | } 32 | 33 | return nil 34 | }) 35 | } 36 | 37 | type Api struct { 38 | SkinsRepo SkinsRepository 39 | } 40 | 41 | func (ctx *Api) Handler() *mux.Router { 42 | router := mux.NewRouter().StrictSlash(true) 43 | router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost) 44 | router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete) 45 | router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete) 46 | 47 | return router 48 | } 49 | 50 | func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) { 51 | validationErrors := validatePostSkinRequest(req) 52 | if validationErrors != nil { 53 | apiBadRequest(resp, validationErrors) 54 | return 55 | } 56 | 57 | identityId, _ := strconv.Atoi(req.Form.Get("identityId")) 58 | username := req.Form.Get("username") 59 | 60 | record, err := ctx.findIdentityOrCleanup(identityId, username) 61 | if err != nil { 62 | panic(err) 63 | } 64 | 65 | if record == nil { 66 | record = &model.Skin{ 67 | UserId: identityId, 68 | Username: username, 69 | } 70 | } 71 | 72 | skinId, _ := strconv.Atoi(req.Form.Get("skinId")) 73 | is18, _ := strconv.ParseBool(req.Form.Get("is1_8")) 74 | isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim")) 75 | 76 | record.Uuid = req.Form.Get("uuid") 77 | record.SkinId = skinId 78 | record.Is1_8 = is18 79 | record.IsSlim = isSlim 80 | record.Url = req.Form.Get("url") 81 | record.MojangTextures = req.Form.Get("mojangTextures") 82 | record.MojangSignature = req.Form.Get("mojangSignature") 83 | 84 | err = ctx.SkinsRepo.SaveSkin(record) 85 | if err != nil { 86 | panic(err) 87 | } 88 | 89 | resp.WriteHeader(http.StatusCreated) 90 | } 91 | 92 | func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) { 93 | id, _ := strconv.Atoi(mux.Vars(req)["id"]) 94 | skin, err := ctx.SkinsRepo.FindSkinByUserId(id) 95 | ctx.deleteSkin(skin, err, resp) 96 | } 97 | 98 | func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) { 99 | username := mux.Vars(req)["username"] 100 | skin, err := ctx.SkinsRepo.FindSkinByUsername(username) 101 | ctx.deleteSkin(skin, err, resp) 102 | } 103 | 104 | func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) { 105 | if err != nil { 106 | panic(err) 107 | } 108 | 109 | if skin == nil { 110 | apiNotFound(resp, "Cannot find record for the requested identifier") 111 | return 112 | } 113 | 114 | err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId) 115 | if err != nil { 116 | panic(err) 117 | } 118 | 119 | resp.WriteHeader(http.StatusNoContent) 120 | } 121 | 122 | func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) { 123 | record, err := ctx.SkinsRepo.FindSkinByUserId(identityId) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | if record != nil { 129 | // The username may have changed in the external database, 130 | // so we need to remove the old association 131 | if record.Username != username { 132 | _ = ctx.SkinsRepo.RemoveSkinByUserId(identityId) 133 | record.Username = username 134 | } 135 | 136 | return record, nil 137 | } 138 | 139 | // If the requested id was not found, then username was reassigned to another user 140 | // who has not uploaded his data to Chrly yet 141 | record, err = ctx.SkinsRepo.FindSkinByUsername(username) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | // If the target username does exist, clear it as it will be reassigned to the new user 147 | if record != nil { 148 | _ = ctx.SkinsRepo.RemoveSkinByUsername(username) 149 | record.UserId = identityId 150 | 151 | return record, nil 152 | } 153 | 154 | return nil, nil 155 | } 156 | 157 | func validatePostSkinRequest(request *http.Request) map[string][]string { 158 | _ = request.ParseForm() 159 | 160 | validationRules := govalidator.MapData{ 161 | "identityId": {"required", "numeric", "min:1"}, 162 | "username": {"required"}, 163 | "uuid": {"required", "uuid_any"}, 164 | "skinId": {"required", "numeric"}, 165 | "url": {}, 166 | "is1_8": {"bool"}, 167 | "isSlim": {"bool"}, 168 | "mojangTextures": {}, 169 | "mojangSignature": {}, 170 | } 171 | 172 | url := request.Form.Get("url") 173 | if url == "" { 174 | validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:0,0") 175 | } else { 176 | validationRules["url"] = append(validationRules["url"], "url") 177 | validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:1,") 178 | validationRules["is1_8"] = append(validationRules["is1_8"], "required") 179 | validationRules["isSlim"] = append(validationRules["isSlim"], "required") 180 | } 181 | 182 | mojangTextures := request.Form.Get("mojangTextures") 183 | if mojangTextures != "" { 184 | validationRules["mojangSignature"] = append(validationRules["mojangSignature"], "required") 185 | } 186 | 187 | validator := govalidator.New(govalidator.Options{ 188 | Request: request, 189 | Rules: validationRules, 190 | RequiredDefault: false, 191 | }) 192 | validationResults := validator.Validate() 193 | 194 | if len(validationResults) != 0 { 195 | return validationResults 196 | } 197 | 198 | return nil 199 | } 200 | -------------------------------------------------------------------------------- /api/mojang/mojang.go: -------------------------------------------------------------------------------- 1 | package mojang 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "strings" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | var HttpClient = &http.Client{ 15 | Timeout: 10 * time.Second, 16 | Transport: &http.Transport{ 17 | MaxIdleConnsPerHost: 1024, 18 | }, 19 | } 20 | 21 | type SignedTexturesResponse struct { 22 | Id string `json:"id"` 23 | Name string `json:"name"` 24 | Props []*Property `json:"properties"` 25 | 26 | once sync.Once 27 | decodedTextures *TexturesProp 28 | decodedErr error 29 | } 30 | 31 | func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) { 32 | t.once.Do(func() { 33 | var texturesProp string 34 | for _, prop := range t.Props { 35 | if prop.Name == "textures" { 36 | texturesProp = prop.Value 37 | break 38 | } 39 | } 40 | 41 | if texturesProp == "" { 42 | return 43 | } 44 | 45 | decodedTextures, err := DecodeTextures(texturesProp) 46 | if err != nil { 47 | t.decodedErr = err 48 | } else { 49 | t.decodedTextures = decodedTextures 50 | } 51 | }) 52 | 53 | return t.decodedTextures, t.decodedErr 54 | } 55 | 56 | type Property struct { 57 | Name string `json:"name"` 58 | Signature string `json:"signature,omitempty"` 59 | Value string `json:"value"` 60 | } 61 | 62 | type ProfileInfo struct { 63 | Id string `json:"id"` 64 | Name string `json:"name"` 65 | IsLegacy bool `json:"legacy,omitempty"` 66 | IsDemo bool `json:"demo,omitempty"` 67 | } 68 | 69 | var ApiMojangDotComAddr = "https://api.mojang.com" 70 | var SessionServerMojangComAddr = "https://sessionserver.mojang.com" 71 | 72 | // Exchanges usernames array to array of uuids 73 | // See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs 74 | func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) { 75 | requestBody, _ := json.Marshal(usernames) 76 | request, err := http.NewRequest("POST", ApiMojangDotComAddr+"/profiles/minecraft", bytes.NewBuffer(requestBody)) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | request.Header.Set("Content-Type", "application/json") 82 | 83 | response, err := HttpClient.Do(request) 84 | if err != nil { 85 | return nil, err 86 | } 87 | defer response.Body.Close() 88 | 89 | if responseErr := validateResponse(response); responseErr != nil { 90 | return nil, responseErr 91 | } 92 | 93 | var result []*ProfileInfo 94 | 95 | body, _ := ioutil.ReadAll(response.Body) 96 | _ = json.Unmarshal(body, &result) 97 | 98 | return result, nil 99 | } 100 | 101 | // Obtains textures information for provided uuid 102 | // See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape 103 | func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) { 104 | normalizedUuid := strings.ReplaceAll(uuid, "-", "") 105 | url := SessionServerMojangComAddr + "/session/minecraft/profile/" + normalizedUuid 106 | if signed { 107 | url += "?unsigned=false" 108 | } 109 | 110 | request, err := http.NewRequest("GET", url, nil) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | response, err := HttpClient.Do(request) 116 | if err != nil { 117 | return nil, err 118 | } 119 | defer response.Body.Close() 120 | 121 | if responseErr := validateResponse(response); responseErr != nil { 122 | return nil, responseErr 123 | } 124 | 125 | var result *SignedTexturesResponse 126 | 127 | body, _ := ioutil.ReadAll(response.Body) 128 | _ = json.Unmarshal(body, &result) 129 | 130 | return result, nil 131 | } 132 | 133 | func validateResponse(response *http.Response) error { 134 | switch { 135 | case response.StatusCode == 204: 136 | return &EmptyResponse{} 137 | case response.StatusCode == 400: 138 | type errorResponse struct { 139 | Error string `json:"error"` 140 | Message string `json:"errorMessage"` 141 | } 142 | 143 | var decodedError *errorResponse 144 | body, _ := ioutil.ReadAll(response.Body) 145 | _ = json.Unmarshal(body, &decodedError) 146 | 147 | return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message} 148 | case response.StatusCode == 403: 149 | return &ForbiddenError{} 150 | case response.StatusCode == 429: 151 | return &TooManyRequestsError{} 152 | case response.StatusCode >= 500: 153 | return &ServerError{Status: response.StatusCode} 154 | } 155 | 156 | return nil 157 | } 158 | 159 | type ResponseError interface { 160 | IsMojangError() bool 161 | } 162 | 163 | // Mojang API doesn't return a 404 Not Found error for non-existent data identifiers 164 | // Instead, they return 204 with an empty body 165 | type EmptyResponse struct { 166 | } 167 | 168 | func (*EmptyResponse) Error() string { 169 | return "204: Empty Response" 170 | } 171 | 172 | func (*EmptyResponse) IsMojangError() bool { 173 | return true 174 | } 175 | 176 | // When passed request params are invalid, Mojang returns 400 Bad Request error 177 | type BadRequestError struct { 178 | ResponseError 179 | ErrorType string 180 | Message string 181 | } 182 | 183 | func (e *BadRequestError) Error() string { 184 | return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message) 185 | } 186 | 187 | func (*BadRequestError) IsMojangError() bool { 188 | return true 189 | } 190 | 191 | // When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization) 192 | type ForbiddenError struct { 193 | ResponseError 194 | } 195 | 196 | func (*ForbiddenError) Error() string { 197 | return "403: Forbidden" 198 | } 199 | 200 | // When you exceed the set limit of requests, this error will be returned 201 | type TooManyRequestsError struct { 202 | ResponseError 203 | } 204 | 205 | func (*TooManyRequestsError) Error() string { 206 | return "429: Too Many Requests" 207 | } 208 | 209 | func (*TooManyRequestsError) IsMojangError() bool { 210 | return true 211 | } 212 | 213 | // ServerError happens when Mojang's API returns any response with 50* status 214 | type ServerError struct { 215 | ResponseError 216 | Status int 217 | } 218 | 219 | func (e *ServerError) Error() string { 220 | return fmt.Sprintf("%d: %s", e.Status, "Server error") 221 | } 222 | 223 | func (*ServerError) IsMojangError() bool { 224 | return true 225 | } 226 | -------------------------------------------------------------------------------- /eventsubscribers/stats_reporter.go: -------------------------------------------------------------------------------- 1 | package eventsubscribers 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/mono83/slf" 10 | 11 | "github.com/elyby/chrly/api/mojang" 12 | ) 13 | 14 | type StatsReporter struct { 15 | slf.StatsReporter 16 | Prefix string 17 | 18 | timersMap map[string]time.Time 19 | timersMutex sync.Mutex 20 | } 21 | 22 | type Reporter interface { 23 | Enable(reporter slf.StatsReporter) 24 | } 25 | 26 | type ReporterFunc func(reporter slf.StatsReporter) 27 | 28 | func (f ReporterFunc) Enable(reporter slf.StatsReporter) { 29 | f(reporter) 30 | } 31 | 32 | // TODO: rework all reporters in the same style as it was there: https://github.com/elyby/chrly/blob/1543e98b/di/db.go#L48-L52 33 | func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) { 34 | s.timersMap = make(map[string]time.Time) 35 | 36 | // Per request events 37 | d.Subscribe("skinsystem:before_request", s.handleBeforeRequest) 38 | d.Subscribe("skinsystem:after_request", s.handleAfterRequest) 39 | 40 | // Authentication events 41 | d.Subscribe("authenticator:success", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5 42 | d.Subscribe("authenticator:success", s.incCounterHandler("authentication.success")) 43 | d.Subscribe("authentication:error", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5 44 | d.Subscribe("authentication:error", s.incCounterHandler("authentication.failed")) 45 | 46 | // Mojang signed textures source events 47 | d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request")) 48 | d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, found bool, err error) { 49 | if err != nil || !found { 50 | return 51 | } 52 | 53 | if uuid == "" { 54 | s.IncCounter("mojang_textures.usernames.cache_hit_nil", 1) 55 | } else { 56 | s.IncCounter("mojang_textures.usernames.cache_hit", 1) 57 | } 58 | }) 59 | d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) { 60 | if err != nil { 61 | return 62 | } 63 | 64 | if textures != nil { 65 | s.IncCounter("mojang_textures.textures.cache_hit", 1) 66 | } 67 | }) 68 | d.Subscribe("mojang_textures:already_processing", s.incCounterHandler("mojang_textures.already_scheduled")) 69 | d.Subscribe("mojang_textures:usernames:after_call", func(username string, profile *mojang.ProfileInfo, err error) { 70 | if err != nil { 71 | return 72 | } 73 | 74 | if profile == nil { 75 | s.IncCounter("mojang_textures.usernames.uuid_miss", 1) 76 | } else { 77 | s.IncCounter("mojang_textures.usernames.uuid_hit", 1) 78 | } 79 | }) 80 | d.Subscribe("mojang_textures:textures:before_call", s.incCounterHandler("mojang_textures.textures.request")) 81 | d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) { 82 | if err != nil { 83 | return 84 | } 85 | 86 | if textures == nil { 87 | s.IncCounter("mojang_textures.usernames.textures_miss", 1) 88 | } else { 89 | s.IncCounter("mojang_textures.usernames.textures_hit", 1) 90 | } 91 | }) 92 | d.Subscribe("mojang_textures:before_result", func(username string, uuid string) { 93 | s.startTimeRecording("mojang_textures_result_time_" + username) 94 | }) 95 | d.Subscribe("mojang_textures:after_result", func(username string, textures *mojang.SignedTexturesResponse, err error) { 96 | s.finalizeTimeRecording("mojang_textures_result_time_"+username, "mojang_textures.result_time") 97 | }) 98 | d.Subscribe("mojang_textures:textures:before_call", func(uuid string) { 99 | s.startTimeRecording("mojang_textures_provider_time_" + uuid) 100 | }) 101 | d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) { 102 | s.finalizeTimeRecording("mojang_textures_provider_time_"+uuid, "mojang_textures.textures.request_time") 103 | }) 104 | 105 | // Mojang UUIDs batch provider metrics 106 | d.Subscribe("mojang_textures:batch_uuids_provider:queued", s.incCounterHandler("mojang_textures.usernames.queued")) 107 | d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) { 108 | s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames))) 109 | s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize)) 110 | if len(usernames) != 0 { 111 | s.startTimeRecording("batch_uuids_provider_round_time_" + strings.Join(usernames, "|")) 112 | } 113 | }) 114 | d.Subscribe("mojang_textures:batch_uuids_provider:result", func(usernames []string, profiles []*mojang.ProfileInfo, err error) { 115 | s.finalizeTimeRecording("batch_uuids_provider_round_time_"+strings.Join(usernames, "|"), "mojang_textures.usernames.round_time") 116 | }) 117 | } 118 | 119 | func (s *StatsReporter) handleBeforeRequest(req *http.Request) { 120 | var key string 121 | m := req.Method 122 | p := req.URL.Path 123 | if p == "/skins" { 124 | key = "skins.get_request" 125 | } else if strings.HasPrefix(p, "/skins/") { 126 | key = "skins.request" 127 | } else if p == "/cloaks" { 128 | key = "capes.get_request" 129 | } else if strings.HasPrefix(p, "/cloaks/") { 130 | key = "capes.request" 131 | } else if strings.HasPrefix(p, "/textures/signed/") { 132 | key = "signed_textures.request" 133 | } else if strings.HasPrefix(p, "/textures/") { 134 | key = "textures.request" 135 | } else if strings.HasPrefix(p, "/profile/") { 136 | key = "profiles.request" 137 | } else if m == http.MethodPost && p == "/api/skins" { 138 | key = "api.skins.post.request" 139 | } else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") { 140 | key = "api.skins.delete.request" 141 | } else { 142 | return 143 | } 144 | 145 | s.IncCounter(key, 1) 146 | } 147 | 148 | func (s *StatsReporter) handleAfterRequest(req *http.Request, code int) { 149 | var key string 150 | m := req.Method 151 | p := req.URL.Path 152 | if m == http.MethodPost && p == "/api/skins" && code == http.StatusCreated { 153 | key = "api.skins.post.success" 154 | } else if m == http.MethodPost && p == "/api/skins" && code == http.StatusBadRequest { 155 | key = "api.skins.post.validation_failed" 156 | } else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNoContent { 157 | key = "api.skins.delete.success" 158 | } else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNotFound { 159 | key = "api.skins.delete.not_found" 160 | } else { 161 | return 162 | } 163 | 164 | s.IncCounter(key, 1) 165 | } 166 | 167 | func (s *StatsReporter) incCounterHandler(name string) func(...interface{}) { 168 | return func(...interface{}) { 169 | s.IncCounter(name, 1) 170 | } 171 | } 172 | 173 | func (s *StatsReporter) startTimeRecording(timeKey string) { 174 | s.timersMutex.Lock() 175 | defer s.timersMutex.Unlock() 176 | s.timersMap[timeKey] = time.Now() 177 | } 178 | 179 | func (s *StatsReporter) finalizeTimeRecording(timeKey string, statName string) { 180 | s.timersMutex.Lock() 181 | defer s.timersMutex.Unlock() 182 | startedAt, ok := s.timersMap[timeKey] 183 | if !ok { 184 | return 185 | } 186 | 187 | delete(s.timersMap, timeKey) 188 | 189 | s.RecordTimer(statName, time.Since(startedAt)) 190 | } 191 | -------------------------------------------------------------------------------- /di/mojang_textures.go: -------------------------------------------------------------------------------- 1 | package di 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/defval/di" 10 | "github.com/spf13/viper" 11 | 12 | "github.com/elyby/chrly/api/mojang" 13 | es "github.com/elyby/chrly/eventsubscribers" 14 | "github.com/elyby/chrly/http" 15 | "github.com/elyby/chrly/mojangtextures" 16 | ) 17 | 18 | var mojangTextures = di.Options( 19 | di.Invoke(interceptMojangApiUrls), 20 | di.Provide(newMojangTexturesProviderFactory), 21 | di.Provide(newMojangTexturesProvider), 22 | di.Provide(newMojangTexturesUuidsProviderFactory), 23 | di.Provide(newMojangTexturesBatchUUIDsProvider), 24 | di.Provide(newMojangTexturesBatchUUIDsProviderStrategyFactory), 25 | di.Provide(newMojangTexturesBatchUUIDsProviderDelayedStrategy), 26 | di.Provide(newMojangTexturesBatchUUIDsProviderFullBusStrategy), 27 | di.Provide(newMojangTexturesRemoteUUIDsProvider), 28 | di.Provide(newMojangSignedTexturesProvider), 29 | di.Provide(newMojangTexturesStorageFactory), 30 | ) 31 | 32 | func interceptMojangApiUrls(config *viper.Viper) error { 33 | apiUrl := config.GetString("mojang.api_base_url") 34 | if apiUrl != "" { 35 | u, err := url.ParseRequestURI(apiUrl) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | mojang.ApiMojangDotComAddr = u.String() 41 | } 42 | 43 | sessionServerUrl := config.GetString("mojang.session_server_base_url") 44 | if sessionServerUrl != "" { 45 | u, err := url.ParseRequestURI(apiUrl) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | mojang.SessionServerMojangComAddr = u.String() 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func newMojangTexturesProviderFactory( 57 | container *di.Container, 58 | config *viper.Viper, 59 | ) (http.MojangTexturesProvider, error) { 60 | config.SetDefault("mojang_textures.enabled", true) 61 | if !config.GetBool("mojang_textures.enabled") { 62 | return &mojangtextures.NilProvider{}, nil 63 | } 64 | 65 | var provider *mojangtextures.Provider 66 | err := container.Resolve(&provider) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | return provider, nil 72 | } 73 | 74 | func newMojangTexturesProvider( 75 | emitter mojangtextures.Emitter, 76 | uuidsProvider mojangtextures.UUIDsProvider, 77 | texturesProvider mojangtextures.TexturesProvider, 78 | storage mojangtextures.Storage, 79 | ) *mojangtextures.Provider { 80 | return &mojangtextures.Provider{ 81 | Emitter: emitter, 82 | UUIDsProvider: uuidsProvider, 83 | TexturesProvider: texturesProvider, 84 | Storage: storage, 85 | } 86 | } 87 | 88 | func newMojangTexturesUuidsProviderFactory( 89 | config *viper.Viper, 90 | container *di.Container, 91 | ) (mojangtextures.UUIDsProvider, error) { 92 | preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver") 93 | if preferredUuidsProvider == "remote" { 94 | var provider *mojangtextures.RemoteApiUuidsProvider 95 | err := container.Resolve(&provider) 96 | 97 | return provider, err 98 | } 99 | 100 | var provider *mojangtextures.BatchUuidsProvider 101 | err := container.Resolve(&provider) 102 | 103 | return provider, err 104 | } 105 | 106 | func newMojangTexturesBatchUUIDsProvider( 107 | container *di.Container, 108 | strategy mojangtextures.BatchUuidsProviderStrategy, 109 | emitter mojangtextures.Emitter, 110 | ) (*mojangtextures.BatchUuidsProvider, error) { 111 | if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker { 112 | config.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute) 113 | 114 | return &namedHealthChecker{ 115 | Name: "mojang-batch-uuids-provider-response", 116 | Checker: es.MojangBatchUuidsProviderResponseChecker( 117 | emitter, 118 | config.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"), 119 | ), 120 | } 121 | }); err != nil { 122 | return nil, err 123 | } 124 | 125 | if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker { 126 | config.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50) 127 | 128 | return &namedHealthChecker{ 129 | Name: "mojang-batch-uuids-provider-queue-length", 130 | Checker: es.MojangBatchUuidsProviderQueueLengthChecker( 131 | emitter, 132 | config.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"), 133 | ), 134 | } 135 | }); err != nil { 136 | return nil, err 137 | } 138 | 139 | return mojangtextures.NewBatchUuidsProvider(context.Background(), strategy, emitter), nil 140 | } 141 | 142 | func newMojangTexturesBatchUUIDsProviderStrategyFactory( 143 | container *di.Container, 144 | config *viper.Viper, 145 | ) (mojangtextures.BatchUuidsProviderStrategy, error) { 146 | config.SetDefault("queue.strategy", "periodic") 147 | 148 | strategyName := config.GetString("queue.strategy") 149 | switch strategyName { 150 | case "periodic": 151 | var strategy *mojangtextures.PeriodicStrategy 152 | err := container.Resolve(&strategy) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | return strategy, nil 158 | case "full-bus": 159 | var strategy *mojangtextures.FullBusStrategy 160 | err := container.Resolve(&strategy) 161 | if err != nil { 162 | return nil, err 163 | } 164 | 165 | return strategy, nil 166 | default: 167 | return nil, fmt.Errorf("unknown queue strategy \"%s\"", strategyName) 168 | } 169 | } 170 | 171 | func newMojangTexturesBatchUUIDsProviderDelayedStrategy(config *viper.Viper) *mojangtextures.PeriodicStrategy { 172 | config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond) 173 | config.SetDefault("queue.batch_size", 10) 174 | 175 | return mojangtextures.NewPeriodicStrategy( 176 | config.GetDuration("queue.loop_delay"), 177 | config.GetInt("queue.batch_size"), 178 | ) 179 | } 180 | 181 | func newMojangTexturesBatchUUIDsProviderFullBusStrategy(config *viper.Viper) *mojangtextures.FullBusStrategy { 182 | config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond) 183 | config.SetDefault("queue.batch_size", 10) 184 | 185 | return mojangtextures.NewFullBusStrategy( 186 | config.GetDuration("queue.loop_delay"), 187 | config.GetInt("queue.batch_size"), 188 | ) 189 | } 190 | 191 | func newMojangTexturesRemoteUUIDsProvider( 192 | container *di.Container, 193 | config *viper.Viper, 194 | emitter mojangtextures.Emitter, 195 | ) (*mojangtextures.RemoteApiUuidsProvider, error) { 196 | remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url")) 197 | if err != nil { 198 | return nil, fmt.Errorf("unable to parse remote url: %w", err) 199 | } 200 | 201 | if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker { 202 | config.SetDefault("healthcheck.mojang_api_textures_provider_cool_down_duration", time.Minute+10*time.Second) 203 | 204 | return &namedHealthChecker{ 205 | Name: "mojang-api-textures-provider-response-checker", 206 | Checker: es.MojangApiTexturesProviderResponseChecker( 207 | emitter, 208 | config.GetDuration("healthcheck.mojang_api_textures_provider_cool_down_duration"), 209 | ), 210 | } 211 | }); err != nil { 212 | return nil, err 213 | } 214 | 215 | return &mojangtextures.RemoteApiUuidsProvider{ 216 | Emitter: emitter, 217 | Url: *remoteUrl, 218 | }, nil 219 | } 220 | 221 | func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider { 222 | return &mojangtextures.MojangApiTexturesProvider{ 223 | Emitter: emitter, 224 | } 225 | } 226 | 227 | func newMojangTexturesStorageFactory( 228 | uuidsStorage mojangtextures.UUIDsStorage, 229 | texturesStorage mojangtextures.TexturesStorage, 230 | ) mojangtextures.Storage { 231 | return &mojangtextures.SeparatedStorage{ 232 | UUIDsStorage: uuidsStorage, 233 | TexturesStorage: texturesStorage, 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /eventsubscribers/logger_test.go: -------------------------------------------------------------------------------- 1 | package eventsubscribers 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "net/http/httptest" 7 | "net/url" 8 | "syscall" 9 | "testing" 10 | 11 | "github.com/mono83/slf" 12 | "github.com/mono83/slf/params" 13 | "github.com/stretchr/testify/mock" 14 | 15 | "github.com/elyby/chrly/api/mojang" 16 | "github.com/elyby/chrly/dispatcher" 17 | ) 18 | 19 | type LoggerMock struct { 20 | mock.Mock 21 | } 22 | 23 | func prepareLoggerArgs(message string, params []slf.Param) []interface{} { 24 | args := []interface{}{message} 25 | for _, v := range params { 26 | args = append(args, v.(interface{})) 27 | } 28 | 29 | return args 30 | } 31 | 32 | func (l *LoggerMock) Trace(message string, params ...slf.Param) { 33 | l.Called(prepareLoggerArgs(message, params)...) 34 | } 35 | 36 | func (l *LoggerMock) Debug(message string, params ...slf.Param) { 37 | l.Called(prepareLoggerArgs(message, params)...) 38 | } 39 | 40 | func (l *LoggerMock) Info(message string, params ...slf.Param) { 41 | l.Called(prepareLoggerArgs(message, params)...) 42 | } 43 | 44 | func (l *LoggerMock) Warning(message string, params ...slf.Param) { 45 | l.Called(prepareLoggerArgs(message, params)...) 46 | } 47 | 48 | func (l *LoggerMock) Error(message string, params ...slf.Param) { 49 | l.Called(prepareLoggerArgs(message, params)...) 50 | } 51 | 52 | func (l *LoggerMock) Alert(message string, params ...slf.Param) { 53 | l.Called(prepareLoggerArgs(message, params)...) 54 | } 55 | 56 | func (l *LoggerMock) Emergency(message string, params ...slf.Param) { 57 | l.Called(prepareLoggerArgs(message, params)...) 58 | } 59 | 60 | type LoggerTestCase struct { 61 | Events [][]interface{} 62 | ExpectedCalls [][]interface{} 63 | } 64 | 65 | var loggerTestCases = map[string]*LoggerTestCase{ 66 | "should log each request to the skinsystem": { 67 | Events: [][]interface{}{ 68 | {"skinsystem:after_request", 69 | (func() *http.Request { 70 | req := httptest.NewRequest("GET", "http://localhost/skins/username.png", nil) 71 | req.Header.Add("User-Agent", "Test user agent") 72 | 73 | return req 74 | })(), 75 | 201, 76 | }, 77 | }, 78 | ExpectedCalls: [][]interface{}{ 79 | {"Info", 80 | ":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"", 81 | mock.MatchedBy(func(strParam params.String) bool { 82 | return strParam.Key == "ip" && strParam.Value == "192.0.2.1" 83 | }), 84 | mock.MatchedBy(func(strParam params.String) bool { 85 | return strParam.Key == "method" && strParam.Value == "GET" 86 | }), 87 | mock.MatchedBy(func(strParam params.String) bool { 88 | return strParam.Key == "path" && strParam.Value == "/skins/username.png" 89 | }), 90 | mock.MatchedBy(func(strParam params.Int) bool { 91 | return strParam.Key == "statusCode" && strParam.Value == 201 92 | }), 93 | mock.MatchedBy(func(strParam params.String) bool { 94 | return strParam.Key == "userAgent" && strParam.Value == "Test user agent" 95 | }), 96 | mock.MatchedBy(func(strParam params.String) bool { 97 | return strParam.Key == "forwardedIp" && strParam.Value == "" 98 | }), 99 | }, 100 | }, 101 | }, 102 | "should log each request to the skinsystem 2": { 103 | Events: [][]interface{}{ 104 | {"skinsystem:after_request", 105 | (func() *http.Request { 106 | req := httptest.NewRequest("GET", "http://localhost/skins/username.png?authlib=1.5.2", nil) 107 | req.Header.Add("User-Agent", "Test user agent") 108 | req.Header.Add("X-Forwarded-For", "1.2.3.4") 109 | 110 | return req 111 | })(), 112 | 201, 113 | }, 114 | }, 115 | ExpectedCalls: [][]interface{}{ 116 | {"Info", 117 | ":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"", 118 | mock.Anything, // Already tested 119 | mock.Anything, // Already tested 120 | mock.MatchedBy(func(strParam params.String) bool { 121 | return strParam.Key == "path" && strParam.Value == "/skins/username.png?authlib=1.5.2" 122 | }), 123 | mock.Anything, // Already tested 124 | mock.Anything, // Already tested 125 | mock.MatchedBy(func(strParam params.String) bool { 126 | return strParam.Key == "forwardedIp" && strParam.Value == "1.2.3.4" 127 | }), 128 | }, 129 | }, 130 | }, 131 | } 132 | 133 | type timeoutError struct{} 134 | 135 | func (*timeoutError) Error() string { return "timeout error" } 136 | func (*timeoutError) Timeout() bool { return true } 137 | func (*timeoutError) Temporary() bool { return false } 138 | 139 | func init() { 140 | // mojang_textures providers errors 141 | for _, providerName := range []string{"usernames", "textures"} { 142 | pn := providerName // Store pointer to iteration value 143 | loggerTestCases["should not log when no error occurred for "+pn+" provider"] = &LoggerTestCase{ 144 | Events: [][]interface{}{ 145 | {"mojang_textures:" + pn + ":after_call", pn, &mojang.ProfileInfo{}, nil}, 146 | }, 147 | ExpectedCalls: nil, 148 | } 149 | 150 | loggerTestCases["should not log when some network errors occured for "+pn+" provider"] = &LoggerTestCase{ 151 | Events: [][]interface{}{ 152 | {"mojang_textures:" + pn + ":after_call", pn, nil, &timeoutError{}}, 153 | {"mojang_textures:" + pn + ":after_call", pn, nil, &url.Error{Op: "GET", URL: "http://localhost"}}, 154 | {"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "read"}}, 155 | {"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "dial"}}, 156 | {"mojang_textures:" + pn + ":after_call", pn, nil, syscall.ECONNREFUSED}, 157 | }, 158 | ExpectedCalls: nil, 159 | } 160 | 161 | loggerTestCases["should log expected mojang errors for "+pn+" provider"] = &LoggerTestCase{ 162 | Events: [][]interface{}{ 163 | {"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.BadRequestError{ 164 | ErrorType: "IllegalArgumentException", 165 | Message: "profileName can not be null or empty.", 166 | }}, 167 | {"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ForbiddenError{}}, 168 | {"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.TooManyRequestsError{}}, 169 | }, 170 | ExpectedCalls: [][]interface{}{ 171 | {"Warning", 172 | ":name: :err", 173 | mock.MatchedBy(func(strParam params.String) bool { 174 | return strParam.Key == "name" && strParam.Value == pn 175 | }), 176 | mock.MatchedBy(func(errParam params.Error) bool { 177 | if errParam.Key != "err" { 178 | return false 179 | } 180 | 181 | if _, ok := errParam.Value.(*mojang.BadRequestError); ok { 182 | return true 183 | } 184 | 185 | if _, ok := errParam.Value.(*mojang.ForbiddenError); ok { 186 | return true 187 | } 188 | 189 | if _, ok := errParam.Value.(*mojang.TooManyRequestsError); ok { 190 | return true 191 | } 192 | 193 | return false 194 | }), 195 | }, 196 | }, 197 | } 198 | 199 | loggerTestCases["should call error when unexpected error occurred for "+pn+" provider"] = &LoggerTestCase{ 200 | Events: [][]interface{}{ 201 | {"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ServerError{Status: 500}}, 202 | }, 203 | ExpectedCalls: [][]interface{}{ 204 | {"Error", 205 | ":name: Unexpected Mojang response error: :err", 206 | mock.MatchedBy(func(strParam params.String) bool { 207 | return strParam.Key == "name" && strParam.Value == pn 208 | }), 209 | mock.MatchedBy(func(errParam params.Error) bool { 210 | if errParam.Key != "err" { 211 | return false 212 | } 213 | 214 | if _, ok := errParam.Value.(*mojang.ServerError); !ok { 215 | return false 216 | } 217 | 218 | return true 219 | }), 220 | }, 221 | }, 222 | } 223 | } 224 | } 225 | 226 | func TestLogger(t *testing.T) { 227 | for name, c := range loggerTestCases { 228 | t.Run(name, func(t *testing.T) { 229 | loggerMock := &LoggerMock{} 230 | if c.ExpectedCalls != nil { 231 | for _, c := range c.ExpectedCalls { 232 | topicName, _ := c[0].(string) 233 | loggerMock.On(topicName, c[1:]...) 234 | } 235 | } 236 | 237 | reporter := &Logger{ 238 | Logger: loggerMock, 239 | } 240 | 241 | d := dispatcher.New() 242 | reporter.ConfigureWithDispatcher(d) 243 | for _, args := range c.Events { 244 | eventName, _ := args[0].(string) 245 | d.Emit(eventName, args[1:]...) 246 | } 247 | 248 | if c.ExpectedCalls != nil { 249 | for _, c := range c.ExpectedCalls { 250 | topicName, _ := c[0].(string) 251 | loggerMock.AssertCalled(t, topicName, c[1:]...) 252 | } 253 | } 254 | }) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /db/redis/redis.go: -------------------------------------------------------------------------------- 1 | package redis 2 | 3 | import ( 4 | "bytes" 5 | "compress/zlib" 6 | "context" 7 | "encoding/json" 8 | "fmt" 9 | "io" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/mediocregopher/radix/v4" 15 | 16 | "github.com/elyby/chrly/model" 17 | ) 18 | 19 | var now = time.Now 20 | 21 | func New(ctx context.Context, addr string, poolSize int) (*Redis, error) { 22 | client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &Redis{ 28 | client: client, 29 | context: ctx, 30 | }, nil 31 | } 32 | 33 | const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username" 34 | const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid" 35 | 36 | type Redis struct { 37 | client radix.Client 38 | context context.Context 39 | } 40 | 41 | func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) { 42 | var skin *model.Skin 43 | err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 44 | var err error 45 | skin, err = findByUsername(ctx, conn, username) 46 | 47 | return err 48 | })) 49 | 50 | return skin, err 51 | } 52 | 53 | func findByUsername(ctx context.Context, conn radix.Conn, username string) (*model.Skin, error) { 54 | redisKey := buildUsernameKey(username) 55 | var encodedResult []byte 56 | err := conn.Do(ctx, radix.Cmd(&encodedResult, "GET", redisKey)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | if len(encodedResult) == 0 { 62 | return nil, nil 63 | } 64 | 65 | result, err := zlibDecode(encodedResult) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | var skin *model.Skin 71 | err = json.Unmarshal(result, &skin) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | // Some old data causing issues in the production. 77 | // TODO: remove after investigation will be finished 78 | if skin.Uuid == "" { 79 | return nil, nil 80 | } 81 | 82 | skin.OldUsername = skin.Username 83 | 84 | return skin, nil 85 | } 86 | 87 | func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) { 88 | var skin *model.Skin 89 | err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 90 | var err error 91 | skin, err = findByUserId(ctx, conn, id) 92 | 93 | return err 94 | })) 95 | 96 | return skin, err 97 | } 98 | 99 | func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, error) { 100 | var username string 101 | err := conn.Do(ctx, radix.FlatCmd(&username, "HGET", accountIdToUsernameKey, id)) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | if username == "" { 107 | return nil, nil 108 | } 109 | 110 | return findByUsername(ctx, conn, username) 111 | } 112 | 113 | func (db *Redis) SaveSkin(skin *model.Skin) error { 114 | return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 115 | return save(ctx, conn, skin) 116 | })) 117 | } 118 | 119 | func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error { 120 | err := conn.Do(ctx, radix.Cmd(nil, "MULTI")) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | // If user has changed username, then we must delete his old username record 126 | if skin.OldUsername != "" && skin.OldUsername != skin.Username { 127 | err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(skin.OldUsername))) 128 | if err != nil { 129 | return err 130 | } 131 | } 132 | 133 | // If this is a new record or if the user has changed username, we set the value in the hash table 134 | if skin.OldUsername != "" || skin.OldUsername != skin.Username { 135 | err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", accountIdToUsernameKey, skin.UserId, skin.Username)) 136 | } 137 | 138 | str, _ := json.Marshal(skin) 139 | err = conn.Do(ctx, radix.FlatCmd(nil, "SET", buildUsernameKey(skin.Username), zlibEncode(str))) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | err = conn.Do(ctx, radix.Cmd(nil, "EXEC")) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | skin.OldUsername = skin.Username 150 | 151 | return nil 152 | } 153 | 154 | func (db *Redis) RemoveSkinByUserId(id int) error { 155 | return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 156 | return removeByUserId(ctx, conn, id) 157 | })) 158 | } 159 | 160 | func removeByUserId(ctx context.Context, conn radix.Conn, id int) error { 161 | record, err := findByUserId(ctx, conn, id) 162 | if err != nil { 163 | return err 164 | } 165 | 166 | err = conn.Do(ctx, radix.Cmd(nil, "MULTI")) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, id)) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | if record != nil { 177 | err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username))) 178 | if err != nil { 179 | return err 180 | } 181 | } 182 | 183 | return conn.Do(ctx, radix.Cmd(nil, "EXEC")) 184 | } 185 | 186 | func (db *Redis) RemoveSkinByUsername(username string) error { 187 | return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 188 | return removeByUsername(ctx, conn, username) 189 | })) 190 | } 191 | 192 | func removeByUsername(ctx context.Context, conn radix.Conn, username string) error { 193 | record, err := findByUsername(ctx, conn, username) 194 | if err != nil { 195 | return err 196 | } 197 | 198 | if record == nil { 199 | return nil 200 | } 201 | 202 | err = conn.Do(ctx, radix.Cmd(nil, "MULTI")) 203 | if err != nil { 204 | return err 205 | } 206 | 207 | err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username))) 208 | if err != nil { 209 | return err 210 | } 211 | 212 | err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, record.UserId)) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | return conn.Do(ctx, radix.Cmd(nil, "EXEC")) 218 | } 219 | 220 | func (db *Redis) GetUuid(username string) (string, bool, error) { 221 | var uuid string 222 | var found bool 223 | err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 224 | var err error 225 | uuid, found, err = findMojangUuidByUsername(ctx, conn, username) 226 | 227 | return err 228 | })) 229 | 230 | return uuid, found, err 231 | } 232 | 233 | func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, bool, error) { 234 | key := strings.ToLower(username) 235 | var result string 236 | err := conn.Do(ctx, radix.Cmd(&result, "HGET", mojangUsernameToUuidKey, key)) 237 | if err != nil { 238 | return "", false, err 239 | } 240 | 241 | if result == "" { 242 | return "", false, nil 243 | } 244 | 245 | parts := strings.Split(result, ":") 246 | // https://github.com/elyby/chrly/issues/28 247 | if len(parts) < 2 { 248 | err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key)) 249 | if err != nil { 250 | return "", false, err 251 | } 252 | 253 | return "", false, fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result) 254 | } 255 | 256 | timestamp, _ := strconv.ParseInt(parts[1], 10, 64) 257 | storedAt := time.Unix(timestamp, 0) 258 | if storedAt.Add(time.Hour * 24 * 30).Before(now()) { 259 | err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key)) 260 | if err != nil { 261 | return "", false, err 262 | } 263 | 264 | return "", false, nil 265 | } 266 | 267 | return parts[0], true, nil 268 | } 269 | 270 | func (db *Redis) StoreUuid(username string, uuid string) error { 271 | return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { 272 | return storeMojangUuid(ctx, conn, username, uuid) 273 | })) 274 | } 275 | 276 | func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error { 277 | value := uuid + ":" + strconv.FormatInt(now().Unix(), 10) 278 | err := conn.Do(ctx, radix.Cmd(nil, "HSET", mojangUsernameToUuidKey, strings.ToLower(username), value)) 279 | if err != nil { 280 | return err 281 | } 282 | 283 | return nil 284 | } 285 | 286 | func (db *Redis) Ping() error { 287 | return db.client.Do(db.context, radix.Cmd(nil, "PING")) 288 | } 289 | 290 | func buildUsernameKey(username string) string { 291 | return "username:" + strings.ToLower(username) 292 | } 293 | 294 | func zlibEncode(str []byte) []byte { 295 | var buff bytes.Buffer 296 | writer := zlib.NewWriter(&buff) 297 | _, _ = writer.Write(str) 298 | _ = writer.Close() 299 | 300 | return buff.Bytes() 301 | } 302 | 303 | func zlibDecode(bts []byte) ([]byte, error) { 304 | buff := bytes.NewReader(bts) 305 | reader, err := zlib.NewReader(buff) 306 | if err != nil { 307 | return nil, err 308 | } 309 | 310 | resultBuffer := new(bytes.Buffer) 311 | _, _ = io.Copy(resultBuffer, reader) 312 | _ = reader.Close() 313 | 314 | return resultBuffer.Bytes(), nil 315 | } 316 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2 h1:koK7z0nSsRiRiBWwa+E714Puh+DO+ZRdIyAXiXzL+lg= 2 | github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA= 3 | github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s= 4 | github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= 5 | github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 9 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/defval/di v1.12.0 h1:xXm7BMX2+Nr0Yyu55DeJl/rmfCA7CQX89f4AGE0zA6U= 11 | github.com/defval/di v1.12.0/go.mod h1:PhVbOxQOvU7oawTOJXXTvqOJp1Dvsjs5PuzMw9gGl0I= 12 | github.com/erickskrauch/EventBus v0.0.0-20200330115301-33b3bc6a7ddc h1:kz3f5uMA1LxfRvJjZmMYG7Zu2rddTfJy6QZofz2YoGQ= 13 | github.com/erickskrauch/EventBus v0.0.0-20200330115301-33b3bc6a7ddc/go.mod h1:RHSo3YFV/SbOGyFR36RKWaXPy3g9nKAmn6ebNLpbco4= 14 | github.com/etherlabsio/healthcheck/v2 v2.0.0 h1:oKq8cbpwM/yNGPXf2Sff6MIjVUjx/pGYFydWzeK2MpA= 15 | github.com/etherlabsio/healthcheck/v2 v2.0.0/go.mod h1:huNVOjKzu6FI1eaO1CGD3ZjhrmPWf5Obu/pzpI6/wog= 16 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 17 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 18 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 19 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 20 | github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea h1:t6e33/eet/VyiHHHKs0cBytUISUWQ/hmQwOlqtFoGEo= 21 | github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= 22 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 23 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 24 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 25 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 26 | github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= 27 | github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= 28 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= 29 | github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= 30 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 31 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 32 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 33 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 34 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 35 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 36 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 37 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 38 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 39 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 40 | github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts= 41 | github.com/mediocregopher/radix/v4 v4.1.4/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE= 42 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 43 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 44 | github.com/mono83/slf v0.0.0-20170919161409-79153e9636db h1:tlz4fTklh5mttoq5M+0yEc5Lap8W/02A2HCXCJn5iz0= 45 | github.com/mono83/slf v0.0.0-20170919161409-79153e9636db/go.mod h1:MfF+zNMZz+5IGY9h8jpFaGLyGoJ2ZPri2FmUVftBoUU= 46 | github.com/mono83/udpwriter v1.0.2 h1:JiQ/N646oZoJA1G0FOMvn2teMt6SdL1KwNH2mszOlQs= 47 | github.com/mono83/udpwriter v1.0.2/go.mod h1:mTDiyLtA0tXoxckkV9T4NUkJTgSQIuO8pAUKx/dSRkQ= 48 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= 49 | github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= 50 | github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= 51 | github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= 52 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 53 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 54 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 55 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 56 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 57 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 58 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 59 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 60 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 61 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 62 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 63 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 64 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 65 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 66 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 67 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 68 | github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= 69 | github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= 70 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 71 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 72 | github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM= 73 | github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 74 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 75 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 76 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 77 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 78 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 79 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 80 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 81 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 82 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 83 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 84 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 85 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 86 | github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU= 87 | github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90= 88 | github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI= 89 | github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao= 90 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 91 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 92 | golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= 93 | golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= 94 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 95 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 96 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 97 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 98 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 99 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 100 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 101 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 102 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 103 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 104 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 105 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 106 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] - xxxx-xx-xx 8 | ### Added 9 | - Allow to remove a skin without removing all user information 10 | - New StatsD metrics: 11 | - Counters: 12 | - `ely.skinsystem.{hostname}.app.profiles.request` 13 | 14 | ### Fixed 15 | - Adjusted Mojang usernames filter to be stickier according to their docs 16 | - `/profile/{username}` endpoint now returns the correct signature for the custom property as well. 17 | 18 | ### Changed 19 | - Bumped Go version to 1.21. 20 | 21 | ### Removed 22 | - Removed mentioning and processing of skin uploading as a file, as this functionality was never implemented and was not planned to be implemented 23 | - StatsD metrics: 24 | - Gauges: 25 | - `ely.skinsystem.{hostname}.app.redis.pool.available` 26 | 27 | ## [4.6.0] - 2021-03-04 28 | ### Added 29 | - `/profile/{username}` endpoint, which returns a profile and its textures, equivalent of the Mojang's 30 | [UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape). 31 | - `/signature-verification-key.der` and `/signature-verification-key.pem` endpoints, which returns the public key in 32 | `DER` or `PEM` formats for signature verification. 33 | 34 | ### Fixed 35 | - [#28](https://github.com/elyby/chrly/issues/28): Added handling of corrupted data from the Mojang's username to UUID 36 | cache. 37 | - [#29](https://github.com/elyby/chrly/issues/29): If a previously cached UUID no longer exists, 38 | it will be invalidated and re-requested. 39 | - Use correct status code for error about empty response from Mojang's API. 40 | 41 | ### Changed 42 | - **BREAKING**: `/cloaks/{username}` and `/textures/{username}` endpoints will no longer return a cape if there are no 43 | textures for the requested username. 44 | - All endpoints are now returns `500` status code when an error occurred during request processing. 45 | - Increased the response timeout for Mojang's API from 3 to 10 seconds. 46 | 47 | ## [4.5.0] - 2020-05-01 48 | ### Added 49 | - [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of 50 | Mojang UUIDs: `full-bus`. 51 | - New configuration param `QUEUE_STRATEGY` with the default value `periodic`. 52 | - New configuration params: `MOJANG_API_BASE_URL` and `MOJANG_SESSION_SERVER_BASE_URL`, that allow you to spoof 53 | Mojang API base addresses. 54 | - New health checker, that ensures that response for textures provider from Mojang's API is valid. 55 | - `dev` Docker images now have the `--cpuprofile` flag, which allows you to run the program with CPU profiling. 56 | - New StatsD metrics: 57 | - Gauges: 58 | - `ely.skinsystem.{hostname}.app.redis.pool.available` 59 | 60 | ### Fixed 61 | - Handle the case when there is no textures property in Mojang's response. 62 | - Handle `SIGTERM` as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker. 63 | - Default connections pool size for Redis. 64 | 65 | ### Changed 66 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time` timer will not be recorded if the iteration was 67 | empty. 68 | 69 | ## [4.4.1] - 2020-04-24 70 | ### Added 71 | - [#20](https://github.com/elyby/chrly/issues/20): Print hostname in the `version` command output. 72 | - [#21](https://github.com/elyby/chrly/issues/21): Print Chrly's version during server startup. 73 | 74 | ### Fixed 75 | - [#22](https://github.com/elyby/chrly/issues/22): Correct version passing during building of the Docker image. 76 | 77 | ## [4.4.0] - 2020-04-22 78 | ### Added 79 | - Mojang textures queue now can be completely disabled via `MOJANG_TEXTURES_ENABLED` param. 80 | - Remote mode for Mojang's textures queue with a new configuration params: `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER` and 81 | `MOJANG_TEXTURES_UUIDS_PROVIDER_URL`. 82 | 83 | For example, to send requests directly to [Mojang's APIs](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time), 84 | set the next configuration: 85 | - `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER=remote` 86 | - `MOJANG_TEXTURES_UUIDS_PROVIDER_URL=https://api.mojang.com/users/profiles/minecraft/` 87 | - Implemented worker mode. The app starts with the only one API endpoint: `/api/worker/mojang-uuid/{username}`, 88 | which is compatible with [Mojang's endpoint](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time) to exchange 89 | username to its UUID. It can be used with some load balancing software to increase throughput of Mojang's textures 90 | proxy by splitting the load across multiple servers with its own IPs. 91 | - Textures extra param is now can be configured via `TEXTURES_EXTRA_PARAM_NAME` and `TEXTURES_EXTRA_PARAM_VALUE`. 92 | - New StatsD metrics: 93 | - Counters: 94 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_hit` 95 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss` 96 | - All incoming requests are now logging to the console in 97 | [Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common). 98 | - Added `/healthcheck` endpoint. 99 | - Graceful server shutdown. 100 | - Panics in http are now logged in Sentry. 101 | 102 | ### Fixed 103 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and 104 | `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size` are now updates even if the queue is empty. 105 | - Don't return an empty object if Mojang's textures don't contain any skin or cape. 106 | - Provides a correct URL scheme for the cape link. 107 | 108 | ### Changed 109 | - **BREAKING**: `QUEUE_LOOP_DELAY` param is now sets as a Go duration, not milliseconds. 110 | For example, default value is now `2s500ms`. 111 | - **BREAKING**: Event `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` has been renamed into 112 | `ely.skinsystem.{hostname}.app.mojang_textures.already_scheduled`. 113 | - Bumped Go version to 1.14. 114 | 115 | ### Removed 116 | - **BREAKING**: `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` counter has been removed. 117 | 118 | ## [4.3.0] - 2019-11-08 119 | ### Added 120 | - 403 Forbidden errors from the Mojang's API are now logged. 121 | - `QUEUE_LOOP_DELAY` configuration param to adjust Mojang's textures queue performance. 122 | 123 | ### Changed 124 | - Mojang's textures queue loop is now has an iteration delay of 2.5 seconds (was 1). 125 | - Bumped Go version to 1.13. 126 | 127 | ## [4.2.3] - 2019-10-03 128 | ### Changed 129 | - Mojang's textures queue batch size [reduced to 10](https://wiki.vg/index.php?title=Mojang_API&type=revision&diff=14964&oldid=14954). 130 | - 400 BadRequest errors from the Mojang's API are now logged. 131 | 132 | ## [4.2.2] - 2019-06-19 133 | ### Fixed 134 | - GC for in-memory textures cache has not been initialized. 135 | 136 | ## [4.2.1] - 2019-05-06 137 | ### Changed 138 | - Improved Keep-Alive settings for HTTP client used to perform requests to Mojang's APIs. 139 | - Mojang's textures queue now has static delay of 1 second after each iteration to prevent strange `429` errors. 140 | - Mojang's textures queue now caches even errored responses for signed textures to avoid `429` errors. 141 | - Mojang's textures queue now caches textures data for 70 seconds to avoid strange `429` errors. 142 | - Mojang's textures queue now doesn't log timeout errors. 143 | 144 | ### Fixed 145 | - Panic when Redis connection is broken. 146 | - Duplication of Redis connections pool for Mojang's textures queue. 147 | - Removed validation rules for `hash` field. 148 | 149 | ## [4.2.0] - 2019-05-02 150 | ### Added 151 | - `CHANGELOG.md` file. 152 | - [#1](https://github.com/elyby/chrly/issues/1): Restored Mojang skins proxy. 153 | - New StatsD metrics: 154 | - Counters: 155 | - `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` 156 | - `ely.skinsystem.{hostname}.app.mojang_textures.request` 157 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit_nil` 158 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queued` 159 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit` 160 | - `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` 161 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_miss` 162 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_hit` 163 | - `ely.skinsystem.{hostname}.app.mojang_textures.textures.cache_hit` 164 | - `ely.skinsystem.{hostname}.app.mojang_textures.textures.request` 165 | - Gauges: 166 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` 167 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size` 168 | - Timers: 169 | - `ely.skinsystem.{hostname}.app.mojang_textures.result_time` 170 | - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time` 171 | - `ely.skinsystem.{hostname}.app.mojang_textures.textures.request_time` 172 | 173 | ### Changed 174 | - Bumped Go version to 1.12. 175 | - Bumped Alpine version to 3.9.3. 176 | 177 | ### Fixed 178 | - `/textures` request no longer proxies request to Mojang in a case when there is no information about the skin, 179 | but there is a cape. 180 | - [#5](https://github.com/elyby/chrly/issues/5): Return Redis connection to the pool after commands are executed 181 | 182 | ### Removed 183 | - `hash` field from `/textures` response because the game doesn't use it and calculates hash by getting the filename 184 | from the textures link instead. 185 | - `hash` field from `POST /api/skins` endpoint. 186 | 187 | [Unreleased]: https://github.com/elyby/chrly/compare/4.6.0...HEAD 188 | [4.6.0]: https://github.com/elyby/chrly/compare/4.5.0...4.6.0 189 | [4.5.0]: https://github.com/elyby/chrly/compare/4.4.1...4.5.0 190 | [4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1 191 | [4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0 192 | [4.3.0]: https://github.com/elyby/chrly/compare/4.2.3...4.3.0 193 | [4.2.3]: https://github.com/elyby/chrly/compare/4.2.2...4.2.3 194 | [4.2.2]: https://github.com/elyby/chrly/compare/4.2.1...4.2.2 195 | [4.2.1]: https://github.com/elyby/chrly/compare/4.2.0...4.2.1 196 | [4.2.0]: https://github.com/elyby/chrly/compare/4.1.1...4.2.0 197 | -------------------------------------------------------------------------------- /api/mojang/mojang_test.go: -------------------------------------------------------------------------------- 1 | package mojang 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/h2non/gock" 8 | 9 | testify "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSignedTexturesResponse(t *testing.T) { 13 | t.Run("DecodeTextures", func(t *testing.T) { 14 | obj := &SignedTexturesResponse{ 15 | Id: "00000000000000000000000000000000", 16 | Name: "mock", 17 | Props: []*Property{ 18 | { 19 | Name: "textures", 20 | Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=", 21 | }, 22 | }, 23 | } 24 | textures, err := obj.DecodeTextures() 25 | testify.Nil(t, err) 26 | testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID) 27 | }) 28 | 29 | t.Run("DecodedTextures without textures prop", func(t *testing.T) { 30 | obj := &SignedTexturesResponse{ 31 | Id: "00000000000000000000000000000000", 32 | Name: "mock", 33 | Props: []*Property{}, 34 | } 35 | textures, err := obj.DecodeTextures() 36 | testify.Nil(t, err) 37 | testify.Nil(t, textures) 38 | }) 39 | } 40 | 41 | func TestUsernamesToUuids(t *testing.T) { 42 | t.Run("exchange usernames to uuids", func(t *testing.T) { 43 | assert := testify.New(t) 44 | 45 | defer gock.Off() 46 | gock.New("https://api.mojang.com"). 47 | Post("/profiles/minecraft"). 48 | JSON([]string{"Thinkofdeath", "maksimkurb"}). 49 | Reply(200). 50 | JSON([]map[string]interface{}{ 51 | { 52 | "id": "4566e69fc90748ee8d71d7ba5aa00d20", 53 | "name": "Thinkofdeath", 54 | "legacy": false, 55 | "demo": true, 56 | }, 57 | { 58 | "id": "0d252b7218b648bfb86c2ae476954d32", 59 | "name": "maksimkurb", 60 | // There is no legacy or demo fields 61 | }, 62 | }) 63 | 64 | client := &http.Client{} 65 | gock.InterceptClient(client) 66 | 67 | HttpClient = client 68 | 69 | result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) 70 | if assert.NoError(err) { 71 | assert.Len(result, 2) 72 | assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id) 73 | assert.Equal("Thinkofdeath", result[0].Name) 74 | assert.False(result[0].IsLegacy) 75 | assert.True(result[0].IsDemo) 76 | 77 | assert.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id) 78 | assert.Equal("maksimkurb", result[1].Name) 79 | assert.False(result[1].IsLegacy) 80 | assert.False(result[1].IsDemo) 81 | } 82 | }) 83 | 84 | t.Run("handle bad request response", func(t *testing.T) { 85 | assert := testify.New(t) 86 | 87 | defer gock.Off() 88 | gock.New("https://api.mojang.com"). 89 | Post("/profiles/minecraft"). 90 | Reply(400). 91 | JSON(map[string]interface{}{ 92 | "error": "IllegalArgumentException", 93 | "errorMessage": "profileName can not be null or empty.", 94 | }) 95 | 96 | client := &http.Client{} 97 | gock.InterceptClient(client) 98 | 99 | HttpClient = client 100 | 101 | result, err := UsernamesToUuids([]string{""}) 102 | assert.Nil(result) 103 | assert.IsType(&BadRequestError{}, err) 104 | assert.EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.") 105 | assert.Implements((*ResponseError)(nil), err) 106 | }) 107 | 108 | t.Run("handle forbidden response", func(t *testing.T) { 109 | assert := testify.New(t) 110 | 111 | defer gock.Off() 112 | gock.New("https://api.mojang.com"). 113 | Post("/profiles/minecraft"). 114 | Reply(403). 115 | BodyString("just because") 116 | 117 | client := &http.Client{} 118 | gock.InterceptClient(client) 119 | 120 | HttpClient = client 121 | 122 | result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) 123 | assert.Nil(result) 124 | assert.IsType(&ForbiddenError{}, err) 125 | assert.EqualError(err, "403: Forbidden") 126 | assert.Implements((*ResponseError)(nil), err) 127 | }) 128 | 129 | t.Run("handle too many requests response", func(t *testing.T) { 130 | assert := testify.New(t) 131 | 132 | defer gock.Off() 133 | gock.New("https://api.mojang.com"). 134 | Post("/profiles/minecraft"). 135 | Reply(429). 136 | JSON(map[string]interface{}{ 137 | "error": "TooManyRequestsException", 138 | "errorMessage": "The client has sent too many requests within a certain amount of time", 139 | }) 140 | 141 | client := &http.Client{} 142 | gock.InterceptClient(client) 143 | 144 | HttpClient = client 145 | 146 | result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) 147 | assert.Nil(result) 148 | assert.IsType(&TooManyRequestsError{}, err) 149 | assert.EqualError(err, "429: Too Many Requests") 150 | assert.Implements((*ResponseError)(nil), err) 151 | }) 152 | 153 | t.Run("handle server error", func(t *testing.T) { 154 | assert := testify.New(t) 155 | 156 | defer gock.Off() 157 | gock.New("https://api.mojang.com"). 158 | Post("/profiles/minecraft"). 159 | Reply(500). 160 | BodyString("500 Internal Server Error") 161 | 162 | client := &http.Client{} 163 | gock.InterceptClient(client) 164 | 165 | HttpClient = client 166 | 167 | result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) 168 | assert.Nil(result) 169 | assert.IsType(&ServerError{}, err) 170 | assert.EqualError(err, "500: Server error") 171 | assert.Equal(500, err.(*ServerError).Status) 172 | assert.Implements((*ResponseError)(nil), err) 173 | }) 174 | } 175 | 176 | func TestUuidToTextures(t *testing.T) { 177 | t.Run("obtain not signed textures", func(t *testing.T) { 178 | assert := testify.New(t) 179 | 180 | defer gock.Off() 181 | gock.New("https://sessionserver.mojang.com"). 182 | Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). 183 | Reply(200). 184 | JSON(map[string]interface{}{ 185 | "id": "4566e69fc90748ee8d71d7ba5aa00d20", 186 | "name": "Thinkofdeath", 187 | "properties": []interface{}{ 188 | map[string]interface{}{ 189 | "name": "textures", 190 | "value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=", 191 | }, 192 | }, 193 | }) 194 | 195 | client := &http.Client{} 196 | gock.InterceptClient(client) 197 | 198 | HttpClient = client 199 | 200 | result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) 201 | if assert.NoError(err) { 202 | assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id) 203 | assert.Equal("Thinkofdeath", result.Name) 204 | assert.Equal(1, len(result.Props)) 205 | assert.Equal("textures", result.Props[0].Name) 206 | assert.Equal(476, len(result.Props[0].Value)) 207 | assert.Equal("", result.Props[0].Signature) 208 | } 209 | }) 210 | 211 | t.Run("obtain signed textures with dashed uuid", func(t *testing.T) { 212 | assert := testify.New(t) 213 | 214 | defer gock.Off() 215 | gock.New("https://sessionserver.mojang.com"). 216 | Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). 217 | MatchParam("unsigned", "false"). 218 | Reply(200). 219 | JSON(map[string]interface{}{ 220 | "id": "4566e69fc90748ee8d71d7ba5aa00d20", 221 | "name": "Thinkofdeath", 222 | "properties": []interface{}{ 223 | map[string]interface{}{ 224 | "name": "textures", 225 | "signature": "signature string", 226 | "value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=", 227 | }, 228 | }, 229 | }) 230 | 231 | client := &http.Client{} 232 | gock.InterceptClient(client) 233 | 234 | HttpClient = client 235 | 236 | result, err := UuidToTextures("4566e69f-c907-48ee-8d71-d7ba5aa00d20", true) 237 | if assert.NoError(err) { 238 | assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id) 239 | assert.Equal("Thinkofdeath", result.Name) 240 | assert.Equal(1, len(result.Props)) 241 | assert.Equal("textures", result.Props[0].Name) 242 | assert.Equal(476, len(result.Props[0].Value)) 243 | assert.Equal("signature string", result.Props[0].Signature) 244 | } 245 | }) 246 | 247 | t.Run("handle empty response", func(t *testing.T) { 248 | assert := testify.New(t) 249 | 250 | defer gock.Off() 251 | gock.New("https://sessionserver.mojang.com"). 252 | Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). 253 | Reply(204). 254 | BodyString("") 255 | 256 | client := &http.Client{} 257 | gock.InterceptClient(client) 258 | 259 | HttpClient = client 260 | 261 | result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) 262 | assert.Nil(result) 263 | assert.IsType(&EmptyResponse{}, err) 264 | assert.EqualError(err, "204: Empty Response") 265 | assert.Implements((*ResponseError)(nil), err) 266 | }) 267 | 268 | t.Run("handle too many requests response", func(t *testing.T) { 269 | assert := testify.New(t) 270 | 271 | defer gock.Off() 272 | gock.New("https://sessionserver.mojang.com"). 273 | Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). 274 | Reply(429). 275 | JSON(map[string]interface{}{ 276 | "error": "TooManyRequestsException", 277 | "errorMessage": "The client has sent too many requests within a certain amount of time", 278 | }) 279 | 280 | client := &http.Client{} 281 | gock.InterceptClient(client) 282 | 283 | HttpClient = client 284 | 285 | result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) 286 | assert.Nil(result) 287 | assert.IsType(&TooManyRequestsError{}, err) 288 | assert.EqualError(err, "429: Too Many Requests") 289 | assert.Implements((*ResponseError)(nil), err) 290 | }) 291 | 292 | t.Run("handle server error", func(t *testing.T) { 293 | assert := testify.New(t) 294 | 295 | defer gock.Off() 296 | gock.New("https://sessionserver.mojang.com"). 297 | Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). 298 | Reply(500). 299 | BodyString("500 Internal Server Error") 300 | 301 | client := &http.Client{} 302 | gock.InterceptClient(client) 303 | 304 | HttpClient = client 305 | 306 | result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) 307 | assert.Nil(result) 308 | assert.IsType(&ServerError{}, err) 309 | assert.EqualError(err, "500: Server error") 310 | assert.Equal(500, err.(*ServerError).Status) 311 | assert.Implements((*ResponseError)(nil), err) 312 | }) 313 | } 314 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Ely.by (https://ely.by) 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /http/skinsystem.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "crypto/rsa" 5 | "crypto/x509" 6 | "encoding/base64" 7 | "encoding/json" 8 | "encoding/pem" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "strings" 13 | "time" 14 | 15 | "github.com/gorilla/mux" 16 | 17 | "github.com/elyby/chrly/api/mojang" 18 | "github.com/elyby/chrly/model" 19 | "github.com/elyby/chrly/utils" 20 | ) 21 | 22 | var timeNow = time.Now 23 | 24 | type SkinsRepository interface { 25 | FindSkinByUsername(username string) (*model.Skin, error) 26 | FindSkinByUserId(id int) (*model.Skin, error) 27 | SaveSkin(skin *model.Skin) error 28 | RemoveSkinByUserId(id int) error 29 | RemoveSkinByUsername(username string) error 30 | } 31 | 32 | type CapesRepository interface { 33 | FindCapeByUsername(username string) (*model.Cape, error) 34 | } 35 | 36 | type MojangTexturesProvider interface { 37 | GetForUsername(username string) (*mojang.SignedTexturesResponse, error) 38 | } 39 | 40 | type TexturesSigner interface { 41 | SignTextures(textures string) (string, error) 42 | GetPublicKey() (*rsa.PublicKey, error) 43 | } 44 | 45 | type Skinsystem struct { 46 | Emitter 47 | SkinsRepo SkinsRepository 48 | CapesRepo CapesRepository 49 | MojangTexturesProvider MojangTexturesProvider 50 | TexturesSigner TexturesSigner 51 | TexturesExtraParamName string 52 | TexturesExtraParamValue string 53 | texturesExtraParamSignature string 54 | } 55 | 56 | func NewSkinsystem( 57 | emitter Emitter, 58 | skinsRepo SkinsRepository, 59 | capesRepo CapesRepository, 60 | mojangTexturesProvider MojangTexturesProvider, 61 | texturesSigner TexturesSigner, 62 | texturesExtraParamName string, 63 | texturesExtraParamValue string, 64 | ) (*Skinsystem, error) { 65 | texturesExtraParamSignature, err := texturesSigner.SignTextures(texturesExtraParamValue) 66 | if err != nil { 67 | return nil, fmt.Errorf("unable to generate signature for textures extra param: %w", err) 68 | } 69 | 70 | return &Skinsystem{ 71 | Emitter: emitter, 72 | SkinsRepo: skinsRepo, 73 | CapesRepo: capesRepo, 74 | MojangTexturesProvider: mojangTexturesProvider, 75 | TexturesSigner: texturesSigner, 76 | TexturesExtraParamName: texturesExtraParamName, 77 | TexturesExtraParamValue: texturesExtraParamValue, 78 | texturesExtraParamSignature: texturesExtraParamSignature, 79 | }, nil 80 | } 81 | 82 | type profile struct { 83 | Id string 84 | Username string 85 | Textures *mojang.TexturesResponse 86 | CapeFile io.Reader 87 | MojangTextures string 88 | MojangSignature string 89 | } 90 | 91 | func (ctx *Skinsystem) Handler() *mux.Router { 92 | router := mux.NewRouter().StrictSlash(true) 93 | 94 | router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet) 95 | router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks") 96 | router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet) 97 | router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet) 98 | router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet) 99 | // Legacy 100 | router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet) 101 | router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet) 102 | // Utils 103 | router.HandleFunc("/signature-verification-key.der", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet) 104 | router.HandleFunc("/signature-verification-key.pem", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet) 105 | 106 | return router 107 | } 108 | 109 | func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) { 110 | profile, err := ctx.getProfile(request, true) 111 | if err != nil { 112 | panic(err) 113 | } 114 | 115 | if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil { 116 | response.WriteHeader(http.StatusNotFound) 117 | return 118 | } 119 | 120 | http.Redirect(response, request, profile.Textures.Skin.Url, 301) 121 | } 122 | 123 | func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) { 124 | username := request.URL.Query().Get("name") 125 | if username == "" { 126 | response.WriteHeader(http.StatusBadRequest) 127 | return 128 | } 129 | 130 | mux.Vars(request)["username"] = username 131 | 132 | ctx.skinHandler(response, request) 133 | } 134 | 135 | func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) { 136 | profile, err := ctx.getProfile(request, true) 137 | if err != nil { 138 | panic(err) 139 | } 140 | 141 | if profile == nil || profile.Textures == nil || (profile.CapeFile == nil && profile.Textures.Cape == nil) { 142 | response.WriteHeader(http.StatusNotFound) 143 | return 144 | } 145 | 146 | if profile.CapeFile == nil { 147 | http.Redirect(response, request, profile.Textures.Cape.Url, 301) 148 | } else { 149 | request.Header.Set("Content-Type", "image/png") 150 | _, _ = io.Copy(response, profile.CapeFile) 151 | } 152 | } 153 | 154 | func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) { 155 | username := request.URL.Query().Get("name") 156 | if username == "" { 157 | response.WriteHeader(http.StatusBadRequest) 158 | return 159 | } 160 | 161 | mux.Vars(request)["username"] = username 162 | 163 | ctx.capeHandler(response, request) 164 | } 165 | 166 | func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) { 167 | profile, err := ctx.getProfile(request, true) 168 | if err != nil { 169 | panic(err) 170 | } 171 | 172 | if profile == nil || profile.Textures == nil || (profile.Textures.Skin == nil && profile.Textures.Cape == nil) { 173 | response.WriteHeader(http.StatusNoContent) 174 | return 175 | } 176 | 177 | responseData, _ := json.Marshal(profile.Textures) 178 | response.Header().Set("Content-Type", "application/json") 179 | _, _ = response.Write(responseData) 180 | } 181 | 182 | func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) { 183 | profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "") 184 | if err != nil { 185 | panic(err) 186 | } 187 | 188 | if profile == nil || profile.MojangTextures == "" { 189 | response.WriteHeader(http.StatusNoContent) 190 | return 191 | } 192 | 193 | profileResponse := &mojang.SignedTexturesResponse{ 194 | Id: profile.Id, 195 | Name: profile.Username, 196 | Props: []*mojang.Property{ 197 | { 198 | Name: "textures", 199 | Signature: profile.MojangSignature, 200 | Value: profile.MojangTextures, 201 | }, 202 | { 203 | Name: ctx.TexturesExtraParamName, 204 | Value: ctx.TexturesExtraParamValue, 205 | }, 206 | }, 207 | } 208 | 209 | responseJson, _ := json.Marshal(profileResponse) 210 | response.Header().Set("Content-Type", "application/json") 211 | _, _ = response.Write(responseJson) 212 | } 213 | 214 | func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) { 215 | profile, err := ctx.getProfile(request, true) 216 | if err != nil { 217 | panic(err) 218 | } 219 | 220 | if profile == nil { 221 | forceResponseWithUuid := request.URL.Query().Get("onUnknownProfileRespondWithUuid") 222 | if forceResponseWithUuid == "" { 223 | response.WriteHeader(http.StatusNoContent) 224 | return 225 | } 226 | 227 | profile = createEmptyProfile() 228 | profile.Id = formatUuid(forceResponseWithUuid) 229 | profile.Username = parseUsername(mux.Vars(request)["username"]) 230 | } 231 | 232 | texturesPropContent := &mojang.TexturesProp{ 233 | Timestamp: utils.UnixMillisecond(timeNow()), 234 | ProfileID: profile.Id, 235 | ProfileName: profile.Username, 236 | Textures: profile.Textures, 237 | } 238 | 239 | texturesPropValueJson, _ := json.Marshal(texturesPropContent) 240 | texturesPropEncodedValue := base64.StdEncoding.EncodeToString(texturesPropValueJson) 241 | 242 | texturesProp := &mojang.Property{ 243 | Name: "textures", 244 | Value: texturesPropEncodedValue, 245 | } 246 | customProp := &mojang.Property{ 247 | Name: ctx.TexturesExtraParamName, 248 | Value: ctx.TexturesExtraParamValue, 249 | } 250 | 251 | if request.URL.Query().Get("unsigned") == "false" { 252 | customProp.Signature = ctx.texturesExtraParamSignature 253 | 254 | texturesSignature, err := ctx.TexturesSigner.SignTextures(texturesProp.Value) 255 | if err != nil { 256 | panic(err) 257 | } 258 | 259 | texturesProp.Signature = texturesSignature 260 | } 261 | 262 | profileResponse := &mojang.SignedTexturesResponse{ 263 | Id: profile.Id, 264 | Name: profile.Username, 265 | Props: []*mojang.Property{ 266 | texturesProp, 267 | customProp, 268 | }, 269 | } 270 | 271 | responseJson, _ := json.Marshal(profileResponse) 272 | response.Header().Set("Content-Type", "application/json") 273 | _, _ = response.Write(responseJson) 274 | } 275 | 276 | func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWriter, request *http.Request) { 277 | publicKey, err := ctx.TexturesSigner.GetPublicKey() 278 | if err != nil { 279 | panic(err) 280 | } 281 | 282 | asn1Bytes, err := x509.MarshalPKIXPublicKey(publicKey) 283 | if err != nil { 284 | panic(err) 285 | } 286 | 287 | if strings.HasSuffix(request.URL.Path, ".pem") { 288 | publicKeyBlock := pem.Block{ 289 | Type: "PUBLIC KEY", 290 | Bytes: asn1Bytes, 291 | } 292 | 293 | publicKeyPemBytes := pem.EncodeToMemory(&publicKeyBlock) 294 | 295 | response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.pem\"") 296 | _, _ = response.Write(publicKeyPemBytes) 297 | } else { 298 | response.Header().Set("Content-Type", "application/octet-stream") 299 | response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.der\"") 300 | _, _ = response.Write(asn1Bytes) 301 | } 302 | } 303 | 304 | // TODO: in v5 should be extracted into some ProfileProvider interface, 305 | // 306 | // which will encapsulate all logics, declared in this method 307 | func (ctx *Skinsystem) getProfile(request *http.Request, proxy bool) (*profile, error) { 308 | username := parseUsername(mux.Vars(request)["username"]) 309 | 310 | skin, err := ctx.SkinsRepo.FindSkinByUsername(username) 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | profile := createEmptyProfile() 316 | 317 | if skin != nil { 318 | profile.Id = formatUuid(skin.Uuid) 319 | profile.Username = skin.Username 320 | } 321 | 322 | if skin != nil && skin.Url != "" { 323 | profile.Textures.Skin = &mojang.SkinTexturesResponse{ 324 | Url: skin.Url, 325 | } 326 | 327 | if skin.IsSlim { 328 | profile.Textures.Skin.Metadata = &mojang.SkinTexturesMetadata{ 329 | Model: "slim", 330 | } 331 | } 332 | 333 | cape, _ := ctx.CapesRepo.FindCapeByUsername(username) 334 | if cape != nil { 335 | profile.CapeFile = cape.File 336 | profile.Textures.Cape = &mojang.CapeTexturesResponse{ 337 | // Use statically http since the application doesn't support TLS 338 | Url: "http://" + request.Host + "/cloaks/" + username, 339 | } 340 | } 341 | 342 | profile.MojangTextures = skin.MojangTextures 343 | profile.MojangSignature = skin.MojangSignature 344 | } else if proxy { 345 | mojangProfile, err := ctx.MojangTexturesProvider.GetForUsername(username) 346 | // If we at least know something about a user, 347 | // than we can ignore an error and return profile without textures 348 | if err != nil && profile.Id != "" { 349 | return profile, nil 350 | } 351 | 352 | if err != nil || mojangProfile == nil { 353 | return nil, err 354 | } 355 | 356 | decodedTextures, err := mojangProfile.DecodeTextures() 357 | if err != nil { 358 | return nil, err 359 | } 360 | 361 | // There might be no textures property 362 | if decodedTextures != nil { 363 | profile.Textures = decodedTextures.Textures 364 | } 365 | 366 | var texturesProp *mojang.Property 367 | for _, prop := range mojangProfile.Props { 368 | if prop.Name == "textures" { 369 | texturesProp = prop 370 | break 371 | } 372 | } 373 | 374 | if texturesProp != nil { 375 | profile.MojangTextures = texturesProp.Value 376 | profile.MojangSignature = texturesProp.Signature 377 | } 378 | 379 | // If user id is unknown at this point, then use values from Mojang profile 380 | if profile.Id == "" { 381 | profile.Id = mojangProfile.Id 382 | profile.Username = mojangProfile.Name 383 | } 384 | } else if profile.Id != "" { 385 | return profile, nil 386 | } else { 387 | return nil, nil 388 | } 389 | 390 | return profile, nil 391 | } 392 | 393 | func createEmptyProfile() *profile { 394 | return &profile{ 395 | Textures: &mojang.TexturesResponse{}, // Field must be initialized to avoid "null" after json encoding 396 | } 397 | } 398 | 399 | func formatUuid(uuid string) string { 400 | return strings.Replace(uuid, "-", "", -1) 401 | } 402 | 403 | func parseUsername(username string) string { 404 | return strings.TrimSuffix(username, ".png") 405 | } 406 | --------------------------------------------------------------------------------