Compare commits

..

88 Commits

Author SHA1 Message Date
ErickSkrauch
e08bb23b3d Prepare 4.4.1 release 2020-04-24 01:02:38 +03:00
ErickSkrauch
2d555d9253 Resolves #20. Print Chrly's version during server startup. 2020-04-23 21:22:12 +03:00
ErickSkrauch
dbefac0e84 Resolves #21. Print hostname in the version command output 2020-04-23 21:21:03 +03:00
ErickSkrauch
15c6816813 Fixed condition when to publish the latest tag to docker hub 2020-04-23 21:00:19 +03:00
ErickSkrauch
bc2f9564d0 Resolves #22. Fix passing version to the binary 2020-04-23 20:43:36 +03:00
ErickSkrauch
fbbb96603c Prepare 4.4.0 release 2020-04-22 15:53:02 +03:00
ErickSkrauch
06b61e1603 Allow to skip deployment with [skip deploy] commit message part 2020-04-22 02:50:47 +03:00
ErickSkrauch
45a93deb24 Update changelog 2020-04-22 02:50:04 +03:00
ErickSkrauch
eec830a828 Apply possible at the moment fossa fixes 2020-04-22 02:44:11 +03:00
ErickSkrauch
7fb12f4a85 Try different project name for fossa 2020-04-21 16:42:09 +03:00
ErickSkrauch
7978462540 Enable FOSSA back 2020-04-21 16:31:36 +03:00
ErickSkrauch
2df31704c1 Fix cape url scheme 2020-04-21 16:24:30 +03:00
ErickSkrauch
6453583e31 Merge branch 'db_rework' 2020-04-21 16:18:20 +03:00
ErickSkrauch
803f3f406b Add tests for filesystem driver 2020-04-21 16:17:52 +03:00
ErickSkrauch
6c59ecbe2e Add tests for redis db adapter 2020-04-21 02:20:45 +03:00
ErickSkrauch
a07905ca5a Rework db layer.
Add database checker.
Rename SkinsRepositoryInterface and CapesRepositoryInterface methods.
2020-04-20 22:18:27 +03:00
ErickSkrauch
632ad4795a Disable FOSSA 2020-04-20 20:12:44 +03:00
ErickSkrauch
4ff164fffd Don't return an empty object if Mojang's textures don't contain any skin or cape 2020-04-20 19:58:31 +03:00
ErickSkrauch
5862d1cbf6 Fix statsd initialization 2020-04-20 19:04:15 +03:00
ErickSkrauch
440b505306 Merge pull request #19 from elyby/di
DI and some refactorings
2020-04-20 18:44:09 +03:00
ErickSkrauch
a4cf29c797 Update README [skip ci] 2020-04-20 18:41:39 +03:00
ErickSkrauch
ced4171eef Update CHANGELOG 2020-04-20 17:22:19 +03:00
ErickSkrauch
e098b8d86f Fix tests 2020-04-20 17:22:04 +03:00
ErickSkrauch
bca1436baf Resolves #18. Log panics to the Sentry 2020-04-20 17:13:16 +03:00
ErickSkrauch
d9fbfe658a Cleanup TODO items 2020-04-20 15:40:20 +03:00
ErickSkrauch
0be85b356b Handling correctly closing the server 2020-04-20 15:34:52 +03:00
ErickSkrauch
cc4cd2874c Drop usage of the SkinNotFoundError and CapeNotFoundError
More accurate redis results checking
Return correct errors from filesystem db driver
2020-04-20 15:16:15 +03:00
ErickSkrauch
2ea4c55d37 Split Dispatcher interface and use it across application 2020-04-20 14:29:33 +03:00
ErickSkrauch
f58b980948 Simplify health checkers initialization 2020-04-20 13:58:53 +03:00
ErickSkrauch
3f81a0c18a Completely move app configuration from cmd to di container
Implemented graceful server shutdown
Extract records manipulating API into separate handlers group
2020-04-19 02:31:09 +03:00
ErickSkrauch
9046338396 Introduce di into the project 2020-04-16 19:42:38 +03:00
ErickSkrauch
0c81494559 Fix tests for worker 2020-04-15 17:52:13 +03:00
ErickSkrauch
c9f6079d90 Enable requests logging for the worker.
Code is still duplicated. Will search for a solution later.
2020-04-15 16:01:30 +03:00
ErickSkrauch
b0ba94751a Merge branch 'worker_health_status'. Resolves #16 2020-04-10 16:38:51 +03:00
ErickSkrauch
2a5be658d8 Implemented batch uuids provider queue length checker 2020-04-10 15:39:48 +03:00
ErickSkrauch
153efdcce6 Implemented error expiring for batch mojang uuids provider response checker 2020-04-10 15:19:10 +03:00
ErickSkrauch
677f48ff3f Simple implementation of the health checker for the worker 2020-04-10 03:07:53 +03:00
ErickSkrauch
db19fe62f2 Add mojang_textures:batch_uuids_provider:result event for the batch uuids provider 2020-04-10 02:47:53 +03:00
ErickSkrauch
f11dee57ff Fix passing emitter to the authentication service 2020-04-07 00:13:04 +03:00
ErickSkrauch
d526b74d07 Enable dependencies caching 2020-04-06 19:37:05 +03:00
ErickSkrauch
270e93d39e Squashed commit of the following:
commit 4994c48a75a8791e841710f5cf11d55a2eb6eac5
Author: ErickSkrauch <erickskrauch@yandex.ru>
Date:   Mon Apr 6 19:01:29 2020 +0300

    Move fossa run to the separate job

commit 61b797194a0641b126f6daf41d1e1e60fbe7bc8a
Author: ErickSkrauch <erickskrauch@yandex.ru>
Date:   Mon Apr 6 18:49:57 2020 +0300

    Update fossa integration

commit 06a8070df84da5122ec55b6b1450d5cd5cbb4cb8
Merge: 53296c7 d79e765
Author: ErickSkrauch <erickskrauch@yandex.ru>
Date:   Mon Apr 6 17:47:55 2020 +0300

    Merge branch 'master' of https://github.com/fossabot/chrly into fossabot-master

commit d79e765bb0
Author: fossabot <badges@fossa.io>
Date:   Sun Apr 5 07:31:43 2020 -0700

    Add license scan report and status

    Signed-off-by: fossabot <badges@fossa.io>
2020-04-06 19:12:10 +03:00
ErickSkrauch
53296c7015 Fix travis warnings 2020-04-06 17:32:10 +03:00
ErickSkrauch
092ea3d4e2 Resolves #13. Add a config param to completely disable Mojang textures provider 2020-04-06 17:16:11 +03:00
ErickSkrauch
03c5a03c73 Bump Go version to 1.14 2020-04-06 17:16:02 +03:00
ErickSkrauch
262babbeaa Merge pull request #17 from elyby/event_dispatcher_refactoring
Event dispatcher refactoring
2020-04-06 16:32:04 +03:00
ErickSkrauch
a459809b6b Update CHANGELOG 2020-04-06 16:30:55 +03:00
ErickSkrauch
2fbeb492f0 Initialize subscribers on app bootstrapping 2020-04-04 19:14:12 +03:00
ErickSkrauch
0546b0519b Do not send debug messages from logger event subscriber. Debug messages will be implemented later in a separate events' listener. 2020-04-04 00:36:56 +03:00
ErickSkrauch
767971a197 Call mojang_textures:after_result event before broadcasting results to listeners 2020-04-04 00:28:50 +03:00
ErickSkrauch
336fcdd072 Replace map with slice in stats_reporter test to avoid maps randomized order 2020-04-03 23:03:15 +03:00
ErickSkrauch
49a1aaada0 Cleanup: remove tests and scripts folders.
Introduce Subscriber interface to make eventsubscriber package independent of package dispatcher.
2020-04-03 22:20:56 +03:00
ErickSkrauch
bd13480175 Added uuid normalization for mojang's uuid to textures request 2020-04-03 20:23:34 +03:00
ErickSkrauch
20a8d90ad7 Add tests for requests logger 2020-04-03 17:18:02 +03:00
ErickSkrauch
532f2206da Add simple requests logger (in style of Apache Common log format).
Fix Gopkg.lock integrity error.
2020-04-02 19:34:39 +03:00
ErickSkrauch
280a55d553 Restored logger for mojang textures providers errors 2020-04-02 02:29:14 +03:00
ErickSkrauch
c5e92e7a02 Added missing tests 2020-03-31 03:53:46 +03:00
ErickSkrauch
880182ccbf Replace event dispatcher by the fork to allow emitting events with nil arguments 2020-03-30 15:44:12 +03:00
ErickSkrauch
e3b9e3c069 Add events handlers to reimplement all statsd metrics, available before refactoring.
Tests aren't working at this time :(
Removed mojang_textures.invalid_username metric.
2020-03-30 12:30:06 +03:00
ErickSkrauch
e1c30a0ba1 Generalize mojang textures events 2020-03-29 22:10:31 +03:00
ErickSkrauch
40c53ea0d9 Add stats reporter events listener, restore all events for http layer, rework authentication middleware and authenticator interface 2020-02-16 13:23:47 +03:00
ErickSkrauch
db728451f8 Implemented event dispatcher 2020-02-08 14:31:47 +03:00
ErickSkrauch
2abe2db469 Integrate event dispatcher into mojangtextures package 2020-02-08 14:27:58 +03:00
ErickSkrauch
b2ee10f72f Completely rework the HTTP app layer structure. Replace a logger with an event dispatcher. Adjust tests to the new app architecture. 2020-01-29 01:34:15 +03:00
ErickSkrauch
fbfe9f4516 Try to fix deploy stage condition 2020-01-06 00:28:31 +03:00
ErickSkrauch
57b7c59929 Make extra property in the signed textures response to be adjusted 2020-01-06 00:16:38 +03:00
ErickSkrauch
0f2b000d70 Update Go badge 2020-01-06 00:05:40 +03:00
ErickSkrauch
af49eef84c Merge pull request #12 from elyby/iss_11
Add remote uuids provider and worker mode
2020-01-05 23:47:24 +03:00
ErickSkrauch
92473d15d6 Replace simple Mutex with RWMutex for in memory textures storage 2020-01-05 23:45:11 +03:00
ErickSkrauch
bc1427dd1f Exclude deployment for pull requests, move docker and sudo requirements to deploy step 2020-01-05 23:35:38 +03:00
ErickSkrauch
a8e4f7ae56 Ugly and dirty solution to sync batch_uuids_provider_test 2020-01-05 23:25:17 +03:00
ErickSkrauch
17f82ec6d3 Resolve golangcibot issues 2020-01-05 20:39:17 +03:00
ErickSkrauch
9946eae73b Update docs 2020-01-05 20:19:41 +03:00
ErickSkrauch
a4a9201034 Add additional synchronization layer for bath_uuids_provider_test 2020-01-03 01:41:51 +03:00
ErickSkrauch
7f9b60ab3a Fix race condition error 2020-01-03 01:04:23 +03:00
ErickSkrauch
5a0c10c1a1 Implemented worker command 2020-01-03 00:51:57 +03:00
ErickSkrauch
1e91aef0a6 Rework http app structure, get rid of the golang/mock package, rewrite http tests 2020-01-01 23:42:45 +03:00
ErickSkrauch
1033069211 Implemented remote api mojang uuids provider 2019-11-24 04:29:22 +03:00
ErickSkrauch
d27caa4922 Add sync channel to batch_uuids_provider_test 2019-11-21 19:33:05 +03:00
ErickSkrauch
0644dfe021 Completely rework mojang textures queue implementation, split it across separate data providers 2019-11-21 02:18:36 +03:00
ErickSkrauch
6fd88e077e Prepare 4.3.0 release 2019-11-08 02:12:31 +03:00
ErickSkrauch
ae185e1daa Bump Go version to 1.13 2019-11-08 02:08:10 +03:00
ErickSkrauch
7353047467 Increase queue loop delay from 1 to 2.5 seconds. Add configuration param to adjust its value 2019-11-08 01:54:16 +03:00
ErickSkrauch
b2a1fd450b Handle 403 Forbidden error from Mojang's API 2019-11-08 01:32:26 +03:00
ErickSkrauch
334e60ff2f Prepare 4.2.3 release 2019-10-03 01:26:34 +03:00
ErickSkrauch
6d6d0e4b79 Decrease queue batch size. Log all 400 response from the Mojang's API. Resolves #10. 2019-10-03 01:24:25 +03:00
ErickSkrauch
0cfed45b64 Prepare 4.2.2 release 2019-06-19 01:02:41 +03:00
ErickSkrauch
f872fe4698 Fix race condition, introduced in the previous commit 2019-06-19 00:56:09 +03:00
ErickSkrauch
5b4761e4e5 Fixes #9. Start GC loop for in-memory textures cache. 2019-06-18 23:34:16 +03:00
92 changed files with 6201 additions and 3786 deletions

13
.fossa.yml Executable file
View File

@@ -0,0 +1,13 @@
version: 2
cli:
server: https://app.fossa.com
fetcher: git
project: github.com:elyby/chrly
analyze:
modules:
- name: chrly
type: go
target: github.com/elyby/chrly
path: .
options:
strategy: dep

View File

@@ -1,42 +1,63 @@
sudo: required
os: linux
dist: xenial
language: go
go:
- 1.12
services:
- docker
- "1.14"
stages:
- test
- name: deploy
if: branch = master OR tag IS present
- Tests
- name: Deploy
if: env(TRAVIS_PULL_REQUEST) IS false AND (branch = master OR tag IS present) AND commit_message !~ /(\[skip deploy\])/
install:
- go get -u github.com/golang/dep/cmd/dep
- dep ensure
cache:
directories:
- $GOPATH/pkg/dep
jobs:
include:
- stage: test
# Tests stage
- name: Unit tests
stage: Tests
services:
- redis
script:
- go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- bash <(curl -s https://codecov.io/bash)
- stage: deploy
- go test -v -race --tags redis -coverprofile=coverage.txt -covermode=atomic ./...
- bash <(curl -s https://codecov.io/bash)
- name: FOSSA
stage: Tests
if: branch = master
before_script:
- curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash
script:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
- export DOCKER_TAG="${TRAVIS_TAG:-dev}"
- export APP_VERSION="${TRAVIS_TAG:-dev-${TRAVIS_COMMIT:0:7}}"
- >
env CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build
-o release/chrly
-ldflags '-extldflags "-static" -X github.com/elyby/chrly/bootstrap.version=$APP_VERSION'
main.go
- docker build -t elyby/chrly:$DOCKER_TAG .
- docker push elyby/chrly:$DOCKER_TAG
- |
if [ ! -z ${TRAVIS_TAG+x} ] && [[ "$TRAVIS_TAG" != *"-"* ]]; then
docker tag elyby/chrly:$DOCKER_TAG elyby/chrly:latest
docker push elyby/chrly:latest
fi
- fossa init
- fossa analyze
# Disable until https://github.com/fossas/fossa-cli/issues/596 will be resolved
# - fossa test
# Deploy stage
- name: Docker image
stage: Deploy
services:
- docker
script:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
- export DOCKER_TAG="${TRAVIS_TAG:-dev}"
- export APP_VERSION="${TRAVIS_TAG:-dev-${TRAVIS_COMMIT:0:7}}"
- >
env CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build
-o release/chrly
-ldflags "-extldflags '-static' -X github.com/elyby/chrly/version.version=$APP_VERSION -X github.com/elyby/chrly/version.commit=$TRAVIS_COMMIT"
main.go
- docker build -t elyby/chrly:$DOCKER_TAG .
- docker push elyby/chrly:$DOCKER_TAG
- |
if [ ! -z ${TRAVIS_TAG:+x} ]; then
docker tag elyby/chrly:$DOCKER_TAG elyby/chrly:latest
docker push elyby/chrly:latest
fi

View File

@@ -4,7 +4,74 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [Unreleased] - xxxx-xx-xx
## [4.4.1] - 2020-04-24
### Added
- [#20](https://github.com/elyby/chrly/issues/20): Print hostname in the `version` command output.
- [#21](https://github.com/elyby/chrly/issues/21): Print Chrly's version during server startup.
### Fixed
- [#22](https://github.com/elyby/chrly/issues/22): Correct version passing during building of the Docker image.
## [4.4.0] - 2020-04-22
### Added
- Mojang textures queue now can be completely disabled via `MOJANG_TEXTURES_ENABLED` param.
- Remote mode for Mojang's textures queue with a new configuration params: `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER` and
`MOJANG_TEXTURES_UUIDS_PROVIDER_URL`.
For example, to send requests directly to [Mojang's APIs](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time),
set the next configuration:
- `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER=remote`
- `MOJANG_TEXTURES_UUIDS_PROVIDER_URL=https://api.mojang.com/users/profiles/minecraft/`
- Implemented worker mode. The app starts with the only one API endpoint: `/api/worker/mojang-uuid/{username}`,
which is compatible with [Mojang's endpoint](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time) to exchange
username to its UUID. It can be used with some load balancing software to increase throughput of Mojang's textures
proxy by splitting the load across multiple servers with its own IPs.
- Textures extra param is now can be configured via `TEXTURES_EXTRA_PARAM_NAME` and `TEXTURES_EXTRA_PARAM_VALUE`.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss`
- All incoming requests are now logging to the console in
[Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common).
- Added `/healthcheck` endpoint.
- Graceful server shutdown.
- Panics in http are now logged in Sentry.
### Fixed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and
`ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size` are now updates even if the queue is empty.
- Don't return an empty object if Mojang's textures don't contain any skin or cape.
- Provides a correct URL scheme for the cape link.
### Changed
- **BREAKING**: `QUEUE_LOOP_DELAY` param is now sets as a Go duration, not milliseconds.
For example, default value is now `2s500ms`.
- **BREAKING**: Event `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` has been renamed into
`ely.skinsystem.{hostname}.app.mojang_textures.already_scheduled`.
- Bumped Go version to 1.14.
### Removed
- **BREAKING**: `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` counter has been removed.
## [4.3.0] - 2019-11-08
### Added
- 403 Forbidden errors from the Mojang's API are now logged.
- `QUEUE_LOOP_DELAY` configuration param to adjust Mojang's textures queue performance.
### Changed
- Mojang's textures queue loop is now has an iteration delay of 2.5 seconds (was 1).
- Bumped Go version to 1.13.
## [4.2.3] - 2019-10-03
### Changed
- Mojang's textures queue batch size [reduced to 10](https://wiki.vg/index.php?title=Mojang_API&type=revision&diff=14964&oldid=14954).
- 400 BadRequest errors from the Mojang's API are now logged.
## [4.2.2] - 2019-06-19
### Fixed
- GC for in-memory textures cache has not been initialized.
## [4.2.1] - 2019-05-06
### Changed
@@ -57,6 +124,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
from the textures link instead.
- `hash` field from `POST /api/skins` endpoint.
[Unreleased]: https://github.com/elyby/chrly/compare/4.2.1...HEAD
[Unreleased]: https://github.com/elyby/chrly/compare/4.4.1...HEAD
[4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1
[4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0
[4.3.0]: https://github.com/elyby/chrly/compare/4.2.3...4.3.0
[4.2.3]: https://github.com/elyby/chrly/compare/4.2.2...4.2.3
[4.2.2]: https://github.com/elyby/chrly/compare/4.2.1...4.2.2
[4.2.1]: https://github.com/elyby/chrly/compare/4.2.0...4.2.1
[4.2.0]: https://github.com/elyby/chrly/compare/4.1.1...4.2.0

37
Gopkg.lock generated
View File

@@ -14,6 +14,15 @@
revision = "f6df55f235c24f236d11dbcf665249a59ac2021f"
version = "1.1"
[[projects]]
branch = "publish_nil_values"
digest = "1:d02c8323070a3d8d8ca039d0d180198ead0a75eac4fb1003af812435a2b391e8"
name = "github.com/asaskevich/EventBus"
packages = ["."]
pruneopts = ""
revision = "33b3bc6a7ddca2f99683c5c3ee86b24f80a7a075"
source = "https://github.com/erickskrauch/EventBus.git"
[[projects]]
digest = "1:c7b11da9bf0707e6920e1b361fbbbbe9b277ef3a198377baa4527f6e31049be0"
name = "github.com/certifi/gocertifi"
@@ -30,6 +39,14 @@
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
digest = "1:2e7c296138d042515eb2995fe58026eaef2c08f660a5f36584faecf34eea3cf0"
name = "github.com/etherlabsio/healthcheck"
packages = ["."]
pruneopts = ""
revision = "dd3d2fd8c3f620a32b7f3cd9b4f0d2f7d0875ab1"
version = "2.0.3"
[[projects]]
digest = "1:9f1e571696860f2b4f8a241b43ce91c6085e7aaed849ccca53f590a4dc7b95bd"
name = "github.com/fsnotify/fsnotify"
@@ -47,12 +64,16 @@
revision = "919484f041ea21e7e27be291cee1d6af7bc98864"
[[projects]]
digest = "1:530233672f656641b365f8efb38ed9fba80e420baff2ce87633813ab3755ed6d"
name = "github.com/golang/mock"
packages = ["gomock"]
branch = "master"
digest = "1:8c9f13aac9e92f3754ea591b39ada87b9f89f1e75c4b90ccbd0b1084069c436f"
name = "github.com/goava/di"
packages = [
".",
"internal/reflection",
"internal/stacktrace",
]
pruneopts = ""
revision = "51421b967af1f557f93a59e0057aaf15ca02e29c"
version = "v1.2.0"
revision = "1eb6eb721bf050edff0efbf15c31636def701b4b"
[[projects]]
digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2"
@@ -309,14 +330,17 @@
input-imports = [
"github.com/SermoDigital/jose/crypto",
"github.com/SermoDigital/jose/jws",
"github.com/asaskevich/EventBus",
"github.com/etherlabsio/healthcheck",
"github.com/getsentry/raven-go",
"github.com/golang/mock/gomock",
"github.com/goava/di",
"github.com/gorilla/mux",
"github.com/h2non/gock",
"github.com/mediocregopher/radix.v2/pool",
"github.com/mediocregopher/radix.v2/redis",
"github.com/mediocregopher/radix.v2/util",
"github.com/mono83/slf",
"github.com/mono83/slf/params",
"github.com/mono83/slf/rays",
"github.com/mono83/slf/recievers/sentry",
"github.com/mono83/slf/recievers/statsd",
@@ -326,6 +350,7 @@
"github.com/spf13/viper",
"github.com/stretchr/testify/assert",
"github.com/stretchr/testify/mock",
"github.com/stretchr/testify/require",
"github.com/stretchr/testify/suite",
"github.com/tevino/abool",
"github.com/thedevsaddam/govalidator",

View File

@@ -36,16 +36,25 @@ ignored = ["github.com/elyby/chrly"]
name = "github.com/tevino/abool"
branch = "master"
[[constraint]]
name = "github.com/asaskevich/EventBus"
source = "https://github.com/erickskrauch/EventBus.git"
branch = "publish_nil_values"
[[constraint]]
name = "github.com/etherlabsio/healthcheck"
version = "2.0.3"
[[constraint]]
name = "github.com/goava/di"
branch = "master"
# Testing dependencies
[[constraint]]
name = "github.com/stretchr/testify"
version = "^1.3.0"
[[constraint]]
name = "github.com/golang/mock"
version = "^1.0.0"
[[constraint]]
name = "github.com/h2non/gock"
version = "^1.0.6"

162
README.md
View File

@@ -5,6 +5,7 @@
[![Coverage][ico-coverage]][link-coverage]
[![Keep a Changelog][ico-changelog]](CHANGELOG.md)
[![Software License][ico-license]](LICENSE)
[![FOSSA Status][ico-fossa]][link-fossa]
Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's
skins system. It's packaged and distributed as a Docker image and can be downloaded from
@@ -57,11 +58,103 @@ docker-compose up -d app
**Variables to adjust:**
| ENV | Description | Example |
|--------------------|------------------------------------------------------------------------------------|-------------------------------------------|
| STORAGE_REDIS_POOL | By default, Chrly creates pool with 10 connection, but you may want to increase it | `20` |
| STATSD_ADDR | StatsD can be used to collect metrics | `localhost:8125` |
| SENTRY_DSN | Sentry can be used to collect app errors | `https://public:private@your.sentry.io/1` |
<table>
<thead>
<tr>
<th>ENV</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>STORAGE_REDIS_HOST</td>
<td>
By default, Chrly tries to connect to the <code>redis</code> host
(by service name in docker-compose configuration).
</td>
<td><code>localhost</code></td>
</tr>
<tr>
<td>STORAGE_REDIS_PORT</td>
<td>
Specifies the Redis connection port.
</td>
<td><code>6379</code></td>
</tr>
<tr>
<td>STORAGE_REDIS_POOL</td>
<td>By default, Chrly creates pool with 10 connection, but you may want to increase it</td>
<td><code>20</code></td>
</tr>
<tr>
<td>STATSD_ADDR</td>
<td>StatsD can be used to collect metrics</td>
<td><code>localhost:8125</code></td>
</tr>
<tr>
<td>SENTRY_DSN</td>
<td>Sentry can be used to collect app errors</td>
<td><code>https://public:private@your.sentry.io/1</code></td>
</tr>
<tr>
<td>QUEUE_LOOP_DELAY</td>
<td>
Parameter is sets the delay before each iteration of the Mojang's textures queue
(<a href="https://golang.org/pkg/time/#ParseDuration">Go's duration</a>)
</td>
<td><code>3s200ms</code></td>
</tr>
<tr>
<td>QUEUE_BATCH_SIZE</td>
<td>
Sets the count of usernames, which will be sent to the
<a href="https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs">Mojang's API to exchange them to their UUIDs</a>.
The current limit is <code>10</code>, but it may change in the future, so you may want to adjust it.
</td>
<td><code>10</code></td>
</tr>
<tr>
<td>MOJANG_TEXTURES_ENABLED</td>
<td>
Allows to completely disable Mojang textures provider for unknown usernames. Enabled by default.
</td>
<td><code>true</code></td>
</tr>
<tr>
<td id="remote-mojang-uuids-provider">MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER</td>
<td>
Specifies the preferred provider of the Mojang's UUIDs. Takes <code>remote</code> value.
In any other case, the local queue will be used.
</td>
<td><code>remote</code></td>
</tr>
<tr>
<td>MOJANG_TEXTURES_UUIDS_PROVIDER_URL</td>
<td>
When the UUIDs driver set to <code>remote</code>, sets the remote URL.
The trailing slash won't cause any problems.
</td>
<td><code>http://remote-provider.com/api/worker/mojang-uuid</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_NAME</td>
<td>
Sets the name of the extra property in the
<a href="#get-texturessignedusername">signed textures</a> response.
</td>
<td><code>your-name</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_VALUE</td>
<td>
Sets the value of the extra property in the
<a href="#get-texturessignedusername">signed textures</a> response.
</td>
<td><code>your awesome joke!</code></td>
</tr>
</tbody>
</table>
If something goes wrong, you can always access logs by executing `docker-compose logs -f app`.
@@ -215,6 +308,55 @@ response will be:
}
```
### Worker mode
The worker mode can be used in cooperation with the [remote server mode](#remote-mojang-uuids-provider)
to exchange Mojang usernames to UUIDs. This mode by itself doesn't solve the problem of
[extremely strict limits](https://github.com/elyby/chrly/issues/10) on the number of requests to the Mojang's API.
But with a proxying load balancer (e.g. HAProxy, Nginx, etc.) it's easy to build a cluster of workers,
which will multiply the bandwidth of the exchanging usernames to its UUIDs.
The instructions for setting up a proxy load balancer are outside the context of this documentation,
but you get the idea ;)
#### `GET /api/worker/mojang-uuid/{username}`
Performs [batch usernames exchange to UUIDs](https://github.com/elyby/chrly/issues/1) and returns the result in the
[same format as it returns from the Mojang's API](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time):
```json
{
"id": "3e3ee6c35afa48abb61e8cd8c42fc0d9",
"name": "ErickSkrauch"
}
```
> **Note**: the results aren't cached.
### Health check
#### `GET /healthcheck`
This endpoint can be used to programmatically check the status of the server.
If all internal checks are successful, the server will return `200` status code with the following body:
```json
{
"status": "OK"
}
```
If any of the checks fails, the server will return `503` status code with the following body:
```json
{
"status": "Service Unavailable",
"errors": {
"mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded"
}
}
```
## Development
First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH`
@@ -257,14 +399,20 @@ If your Redis instance isn't located at the `localhost`, you can change host by
`STORAGE_REDIS_HOST`.
After all of that `go run main.go serve` should successfully start the application.
To run tests execute `go test ./...`. If your Go version is older than 1.9, then run a `/script/test`.
To run tests execute `go test ./...`.
[ico-lang]: https://img.shields.io/badge/lang-go%201.12-blue.svg?style=flat-square
## License
[![FOSSA Status][ico-fossa-big]][link-fossa]
[ico-lang]: https://img.shields.io/badge/lang-go%201.14-blue.svg?style=flat-square
[ico-build]: https://img.shields.io/travis/elyby/chrly.svg?style=flat-square
[ico-coverage]: https://img.shields.io/codecov/c/github/elyby/chrly.svg?style=flat-square
[ico-changelog]: https://img.shields.io/badge/keep%20a-changelog-orange.svg?style=flat-square
[ico-license]: https://img.shields.io/github/license/elyby/chrly.svg?style=flat-square
[ico-fossa]: https://app.fossa.io/api/projects/git%2Bgithub.com%2Felyby%2Fchrly.svg?type=shield
[ico-fossa-big]: https://app.fossa.io/api/projects/git%2Bgithub.com%2Felyby%2Fchrly.svg?type=large
[link-go]: https://golang.org
[link-build]: https://travis-ci.org/elyby/chrly
[link-coverage]: https://codecov.io/gh/elyby/chrly
[link-fossa]: https://app.fossa.io/projects/git%2Bgithub.com%2Felyby%2Fchrly

View File

@@ -3,8 +3,10 @@ package mojang
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"time"
)
@@ -85,7 +87,8 @@ func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
// Obtains textures information for provided uuid
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + uuid
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + normalizedUuid
if signed {
url += "?unsigned=false"
}
@@ -125,6 +128,8 @@ func validateResponse(response *http.Response) error {
_ = json.Unmarshal(body, &decodedError)
return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message}
case response.StatusCode == 403:
return &ForbiddenError{}
case response.StatusCode == 429:
return &TooManyRequestsError{}
case response.StatusCode >= 500:
@@ -144,7 +149,7 @@ type EmptyResponse struct {
}
func (*EmptyResponse) Error() string {
return "Empty Response"
return "200: Empty Response"
}
func (*EmptyResponse) IsMojangError() bool {
@@ -159,20 +164,29 @@ type BadRequestError struct {
}
func (e *BadRequestError) Error() string {
return e.Message
return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
}
func (*BadRequestError) IsMojangError() bool {
return true
}
// When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization)
type ForbiddenError struct {
ResponseError
}
func (*ForbiddenError) Error() string {
return "403: Forbidden"
}
// When you exceed the set limit of requests, this error will be returned
type TooManyRequestsError struct {
ResponseError
}
func (*TooManyRequestsError) Error() string {
return "Too Many Requests"
return "429: Too Many Requests"
}
func (*TooManyRequestsError) IsMojangError() bool {
@@ -186,7 +200,7 @@ type ServerError struct {
}
func (e *ServerError) Error() string {
return "Server error"
return fmt.Sprintf("%d: %s", e.Status, "Server error")
}
func (*ServerError) IsMojangError() bool {

View File

@@ -98,7 +98,28 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{""})
assert.Nil(result)
assert.IsType(&BadRequestError{}, err)
assert.EqualError(err, "profileName can not be null or empty.")
assert.EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
assert.Implements((*ResponseError)(nil), err)
})
t.Run("handle forbidden response", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(403).
BodyString("just because")
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&ForbiddenError{}, err)
assert.EqualError(err, "403: Forbidden")
assert.Implements((*ResponseError)(nil), err)
})
@@ -122,7 +143,7 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&TooManyRequestsError{}, err)
assert.EqualError(err, "Too Many Requests")
assert.EqualError(err, "429: Too Many Requests")
assert.Implements((*ResponseError)(nil), err)
})
@@ -143,7 +164,7 @@ func TestUsernamesToUuids(t *testing.T) {
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&ServerError{}, err)
assert.EqualError(err, "Server error")
assert.EqualError(err, "500: Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})
@@ -184,7 +205,7 @@ func TestUuidToTextures(t *testing.T) {
}
})
t.Run("obtain signed textures", func(t *testing.T) {
t.Run("obtain signed textures with dashed uuid", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
@@ -209,7 +230,7 @@ func TestUuidToTextures(t *testing.T) {
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", true)
result, err := UuidToTextures("4566e69f-c907-48ee-8d71-d7ba5aa00d20", true)
if assert.NoError(err) {
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
assert.Equal("Thinkofdeath", result.Name)
@@ -237,7 +258,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&EmptyResponse{}, err)
assert.EqualError(err, "Empty Response")
assert.EqualError(err, "200: Empty Response")
assert.Implements((*ResponseError)(nil), err)
})
@@ -261,7 +282,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&TooManyRequestsError{}, err)
assert.EqualError(err, "Too Many Requests")
assert.EqualError(err, "429: Too Many Requests")
assert.Implements((*ResponseError)(nil), err)
})
@@ -282,7 +303,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&ServerError{}, err)
assert.EqualError(err, "Server error")
assert.EqualError(err, "500: Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})

View File

@@ -1,53 +0,0 @@
package queue
import (
"sync"
"github.com/elyby/chrly/api/mojang"
)
type broadcastMap struct {
lock sync.Mutex
listeners map[string][]chan *mojang.SignedTexturesResponse
}
func newBroadcaster() *broadcastMap {
return &broadcastMap{
listeners: make(map[string][]chan *mojang.SignedTexturesResponse),
}
}
// Returns a boolean value, which will be true if the username passed didn't exist before
func (c *broadcastMap) AddListener(username string, resultChan chan *mojang.SignedTexturesResponse) bool {
c.lock.Lock()
defer c.lock.Unlock()
val, alreadyHasSource := c.listeners[username]
if alreadyHasSource {
c.listeners[username] = append(val, resultChan)
return false
}
c.listeners[username] = []chan *mojang.SignedTexturesResponse{resultChan}
return true
}
func (c *broadcastMap) BroadcastAndRemove(username string, result *mojang.SignedTexturesResponse) {
c.lock.Lock()
defer c.lock.Unlock()
val, ok := c.listeners[username]
if !ok {
return
}
for _, channel := range val {
go func(channel chan *mojang.SignedTexturesResponse) {
channel <- result
close(channel)
}(channel)
}
delete(c.listeners, username)
}

View File

@@ -1,75 +0,0 @@
package queue
import (
"github.com/elyby/chrly/api/mojang"
testify "github.com/stretchr/testify/assert"
"testing"
)
func TestBroadcastMap_GetOrAppend(t *testing.T) {
t.Run("first call when username didn't exist before should return true", func(t *testing.T) {
assert := testify.New(t)
broadcaster := newBroadcaster()
channel := make(chan *mojang.SignedTexturesResponse)
isFirstListener := broadcaster.AddListener("mock", channel)
assert.True(isFirstListener)
listeners, ok := broadcaster.listeners["mock"]
assert.True(ok)
assert.Len(listeners, 1)
assert.Equal(channel, listeners[0])
})
t.Run("subsequent calls should return false", func(t *testing.T) {
assert := testify.New(t)
broadcaster := newBroadcaster()
channel1 := make(chan *mojang.SignedTexturesResponse)
isFirstListener := broadcaster.AddListener("mock", channel1)
assert.True(isFirstListener)
channel2 := make(chan *mojang.SignedTexturesResponse)
isFirstListener = broadcaster.AddListener("mock", channel2)
assert.False(isFirstListener)
channel3 := make(chan *mojang.SignedTexturesResponse)
isFirstListener = broadcaster.AddListener("mock", channel3)
assert.False(isFirstListener)
})
}
func TestBroadcastMap_BroadcastAndRemove(t *testing.T) {
t.Run("should broadcast to all listeners and remove the key", func(t *testing.T) {
assert := testify.New(t)
broadcaster := newBroadcaster()
channel1 := make(chan *mojang.SignedTexturesResponse)
channel2 := make(chan *mojang.SignedTexturesResponse)
broadcaster.AddListener("mock", channel1)
broadcaster.AddListener("mock", channel2)
result := &mojang.SignedTexturesResponse{Id: "mockUuid"}
broadcaster.BroadcastAndRemove("mock", result)
assert.Equal(result, <-channel1)
assert.Equal(result, <-channel2)
channel3 := make(chan *mojang.SignedTexturesResponse)
isFirstListener := broadcaster.AddListener("mock", channel3)
assert.True(isFirstListener)
})
t.Run("call on not exists username", func(t *testing.T) {
assert := testify.New(t)
assert.NotPanics(func() {
broadcaster := newBroadcaster()
broadcaster.BroadcastAndRemove("mock", &mojang.SignedTexturesResponse{})
})
})
}

View File

@@ -1,56 +0,0 @@
// Based on the implementation from https://flaviocopes.com/golang-data-structure-queue/
package queue
import (
"sync"
"github.com/elyby/chrly/api/mojang"
)
type jobItem struct {
Username string
RespondTo chan *mojang.SignedTexturesResponse
}
type jobsQueue struct {
lock sync.Mutex
items []*jobItem
}
func (s *jobsQueue) New() *jobsQueue {
s.items = []*jobItem{}
return s
}
func (s *jobsQueue) Enqueue(t *jobItem) {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, t)
}
func (s *jobsQueue) Dequeue(n int) []*jobItem {
s.lock.Lock()
defer s.lock.Unlock()
if n > s.Size() {
n = s.Size()
}
items := s.items[0:n]
s.items = s.items[n:len(s.items)]
return items
}
func (s *jobsQueue) IsEmpty() bool {
s.lock.Lock()
defer s.lock.Unlock()
return len(s.items) == 0
}
func (s *jobsQueue) Size() int {
return len(s.items)
}

View File

@@ -1,47 +0,0 @@
package queue
import (
"testing"
testify "github.com/stretchr/testify/assert"
)
func TestEnqueue(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{Username: "username1"})
s.Enqueue(&jobItem{Username: "username2"})
s.Enqueue(&jobItem{Username: "username3"})
assert.Equal(3, s.Size())
}
func TestDequeueN(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{Username: "username1"})
s.Enqueue(&jobItem{Username: "username2"})
s.Enqueue(&jobItem{Username: "username3"})
s.Enqueue(&jobItem{Username: "username4"})
items := s.Dequeue(2)
assert.Len(items, 2)
assert.Equal("username1", items[0].Username)
assert.Equal("username2", items[1].Username)
assert.Equal(2, s.Size())
items = s.Dequeue(40)
assert.Len(items, 2)
assert.Equal("username3", items[0].Username)
assert.Equal("username4", items[1].Username)
assert.True(s.IsEmpty())
}
func createQueue() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}

View File

@@ -1,209 +0,0 @@
package queue
import (
"net"
"net/url"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
var usernamesToUuids = mojang.UsernamesToUuids
var uuidToTextures = mojang.UuidToTextures
var uuidsQueueIterationDelay = time.Second
var forever = func() bool {
return true
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
type JobsQueue struct {
Storage Storage
Logger wd.Watchdog
onFirstCall sync.Once
queue jobsQueue
broadcast *broadcastMap
}
func (ctx *JobsQueue) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse {
// TODO: convert username to lower case
ctx.onFirstCall.Do(func() {
ctx.queue.New()
ctx.broadcast = newBroadcaster()
ctx.startQueue()
})
responseChan := make(chan *mojang.SignedTexturesResponse)
if !allowedUsernamesRegex.MatchString(username) {
ctx.Logger.IncCounter("mojang_textures.invalid_username", 1)
go func() {
responseChan <- nil
close(responseChan)
}()
return responseChan
}
ctx.Logger.IncCounter("mojang_textures.request", 1)
uuid, err := ctx.Storage.GetUuid(username)
if err == nil && uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
go func() {
responseChan <- nil
close(responseChan)
}()
return responseChan
}
isFirstListener := ctx.broadcast.AddListener(username, responseChan)
if isFirstListener {
start := time.Now()
// TODO: respond nil if processing takes more than 5 seconds
resultChan := make(chan *mojang.SignedTexturesResponse)
if uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.queued", 1)
ctx.queue.Enqueue(&jobItem{username, resultChan})
} else {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit", 1)
go func() {
resultChan <- ctx.getTextures(uuid)
}()
}
go func() {
result := <-resultChan
close(resultChan)
ctx.broadcast.BroadcastAndRemove(username, result)
ctx.Logger.RecordTimer("mojang_textures.result_time", time.Since(start))
}()
} else {
ctx.Logger.IncCounter("mojang_textures.already_in_queue", 1)
}
return responseChan
}
func (ctx *JobsQueue) startQueue() {
go func() {
time.Sleep(uuidsQueueIterationDelay)
for forever() {
start := time.Now()
ctx.queueRound()
elapsed := time.Since(start)
ctx.Logger.RecordTimer("mojang_textures.usernames.round_time", elapsed)
time.Sleep(uuidsQueueIterationDelay)
}
}()
}
func (ctx *JobsQueue) queueRound() {
if ctx.queue.IsEmpty() {
return
}
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(100)
ctx.Logger.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(jobs)))
ctx.Logger.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize-len(jobs)))
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.Username)
}
profiles, err := usernamesToUuids(usernames)
if err != nil {
ctx.handleResponseError(err, "usernames")
for _, job := range jobs {
job.RespondTo <- nil
}
return
}
for _, job := range jobs {
go func(job *jobItem) {
var uuid string
// The profiles in the response are not ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.Username, profile.Name) {
uuid = profile.Id
break
}
}
_ = ctx.Storage.StoreUuid(job.Username, uuid)
if uuid == "" {
job.RespondTo <- nil
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_miss", 1)
} else {
job.RespondTo <- ctx.getTextures(uuid)
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
}(job)
}
}
func (ctx *JobsQueue) getTextures(uuid string) *mojang.SignedTexturesResponse {
existsTextures, err := ctx.Storage.GetTextures(uuid)
if err == nil {
ctx.Logger.IncCounter("mojang_textures.textures.cache_hit", 1)
return existsTextures
}
ctx.Logger.IncCounter("mojang_textures.textures.request", 1)
start := time.Now()
result, err := uuidToTextures(uuid, true)
ctx.Logger.RecordTimer("mojang_textures.textures.request_time", time.Since(start))
if err != nil {
ctx.handleResponseError(err, "textures")
}
// Mojang can respond with an error, but count it as a hit, so store result even if the textures is nil
ctx.Storage.StoreTextures(uuid, result)
return result
}
func (ctx *JobsQueue) handleResponseError(err error, threadName string) {
ctx.Logger.Debug(":name: Got response error :err", wd.NameParam(threadName), wd.ErrParam(err))
switch err.(type) {
case mojang.ResponseError:
if _, ok := err.(*mojang.TooManyRequestsError); ok {
ctx.Logger.Warning(":name: Got 429 Too Many Requests :err", wd.NameParam(threadName), wd.ErrParam(err))
}
return
case net.Error:
if err.(net.Error).Timeout() {
return
}
if _, ok := err.(*url.Error); ok {
return
}
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
return
}
if err == syscall.ECONNREFUSED {
return
}
}
ctx.Logger.Emergency(":name: Unknown Mojang response error: :err", wd.NameParam(threadName), wd.ErrParam(err))
}

View File

@@ -1,520 +0,0 @@
package queue
import (
"crypto/rand"
"encoding/base64"
"errors"
"net"
"net/url"
"strings"
"syscall"
"time"
"github.com/elyby/chrly/api/mojang"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
mocks "github.com/elyby/chrly/tests"
)
type mojangApiMocks struct {
mock.Mock
}
func (o *mojangApiMocks) UsernamesToUuids(usernames []string) ([]*mojang.ProfileInfo, error) {
args := o.Called(usernames)
var result []*mojang.ProfileInfo
if casted, ok := args.Get(0).([]*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
func (o *mojangApiMocks) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) {
args := o.Called(uuid, signed)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type mockStorage struct {
mock.Mock
}
func (m *mockStorage) GetUuid(username string) (string, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
}
func (m *mockStorage) StoreUuid(username string, uuid string) error {
args := m.Called(username, uuid)
return args.Error(0)
}
func (m *mockStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
func (m *mockStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
m.Called(uuid, textures)
}
type queueTestSuite struct {
suite.Suite
Queue *JobsQueue
Storage *mockStorage
MojangApi *mojangApiMocks
Logger *mocks.WdMock
Iterate func()
iterateChan chan bool
done func()
}
func (suite *queueTestSuite) SetupSuite() {
uuidsQueueIterationDelay = 0
}
func (suite *queueTestSuite) SetupTest() {
suite.Storage = &mockStorage{}
suite.Logger = &mocks.WdMock{}
suite.Queue = &JobsQueue{Storage: suite.Storage, Logger: suite.Logger}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.MojangApi = new(mojangApiMocks)
usernamesToUuids = suite.MojangApi.UsernamesToUuids
uuidToTextures = suite.MojangApi.UuidToTextures
}
func (suite *queueTestSuite) TearDownTest() {
suite.done()
time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls
suite.MojangApi.AssertExpectations(suite.T())
suite.Storage.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func (suite *queueTestSuite) TestReceiveTexturesForOneUsernameWithoutAnyCache() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result)
}
func (suite *queueTestSuite) TestReceiveTexturesForFewUsernamesWithoutAnyCache() {
expectedResult1 := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
expectedResult2 := &mojang.SignedTexturesResponse{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Twice()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Twice()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Twice()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Twice()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "Thinkofdeath").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("StoreUuid", "Thinkofdeath", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("GetTextures", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult1).Once()
suite.Storage.On("StoreTextures", "4566e69fc90748ee8d71d7ba5aa00d20", expectedResult2).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb", "Thinkofdeath"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult1, nil)
suite.MojangApi.On("UuidToTextures", "4566e69fc90748ee8d71d7ba5aa00d20", true).Once().Return(expectedResult2, nil)
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
resultChan2 := suite.Queue.GetTexturesForUsername("Thinkofdeath")
suite.Iterate()
suite.Assert().Equal(expectedResult1, <-resultChan1)
suite.Assert().Equal(expectedResult2, <-resultChan2)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUuid() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil)
// Storage.StoreUuid shouldn't be called
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
// MojangApi.UsernamesToUuids shouldn't be called
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that there is no iteration
result := <-resultChan
suite.Assert().Equal(expectedResult, result)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithFullyCachedResult() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.textures.cache_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil)
// Storage.StoreUuid shouldn't be called
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(expectedResult, nil)
// Storage.StoreTextures shouldn't be called
// MojangApi.UsernamesToUuids shouldn't be called
// MojangApi.UuidToTextures shouldn't be called
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that there is no iteration
result := <-resultChan
suite.Assert().Equal(expectedResult, result)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUnknownUuid() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", nil)
// Storage.StoreUuid shouldn't be called
// Storage.GetTextures shouldn't be called
// Storage.StoreTextures shouldn't be called
// MojangApi.UsernamesToUuids shouldn't be called
// MojangApi.UuidToTextures shouldn't be called
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that there is no iteration
suite.Assert().Nil(<-resultChan)
}
func (suite *queueTestSuite) TestReceiveTexturesForMoreThan100Usernames() {
usernames := make([]string, 120)
for i := 0; i < 120; i++ {
usernames[i] = randStr(8)
}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Times(120)
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Times(120)
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(100)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(20)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(20)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Times(120)
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Times(120)
suite.Storage.On("GetUuid", mock.Anything).Times(120).Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", mock.Anything, "").Times(120).Return(nil) // should be called with "" if username is not compared to uuid
// Storage.GetTextures and Storage.SetTextures shouldn't be called
suite.MojangApi.On("UsernamesToUuids", usernames[0:100]).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[100:120]).Once().Return([]*mojang.ProfileInfo{}, nil)
channels := make([]chan *mojang.SignedTexturesResponse, 120)
for i, username := range usernames {
channels[i] = suite.Queue.GetTexturesForUsername(username)
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
}
func (suite *queueTestSuite) TestReceiveTexturesForTheSameUsernames() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Equal(expectedResult, <-resultChan1)
suite.Assert().Equal(expectedResult, <-resultChan2)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameThatAlreadyProcessing() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).
Once().
After(10*time.Millisecond). // Simulate long round trip
Return(expectedResult, nil)
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that for entire test there is only one iteration
suite.Iterate()
// Let it meet delayed UuidToTextures request
time.Sleep(5 * time.Millisecond)
resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Assert().Equal(expectedResult, <-resultChan1)
suite.Assert().Equal(expectedResult, <-resultChan2)
}
func (suite *queueTestSuite) TestDoNothingWhenNoTasks() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "").Once().Return(nil)
// Storage.GetTextures and Storage.StoreTextures shouldn't be called
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finish
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
// Let it to perform a few more iterations to ensure, that there is no calls to external APIs
suite.Iterate()
suite.Iterate()
}
type timeoutError struct {
}
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
var expectedErrors = []error{
&mojang.BadRequestError{},
&mojang.TooManyRequestsError{},
&mojang.ServerError{},
&timeoutError{},
&url.Error{Op: "GET", URL: "http://localhost"},
&net.OpError{Op: "read"},
&net.OpError{Op: "dial"},
syscall.ECONNREFUSED,
}
func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
for _, err := range expectedErrors {
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, err)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
suite.MojangApi.AssertExpectations(suite.T())
suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, errors.New("unexpected error"))
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
}
func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", (*mojang.SignedTexturesResponse)(nil))
for _, err := range expectedErrors {
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, err)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
suite.MojangApi.AssertExpectations(suite.T())
suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", (*mojang.SignedTexturesResponse)(nil))
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, errors.New("unexpected error"))
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
}
func (suite *queueTestSuite) TestReceiveTexturesForNotAllowedMojangUsername() {
suite.Logger.On("IncCounter", "mojang_textures.invalid_username", int64(1)).Once()
resultChan := suite.Queue.GetTexturesForUsername("Not allowed")
suite.Assert().Nil(<-resultChan)
}
func TestJobsQueueSuite(t *testing.T) {
suite.Run(t, new(queueTestSuite))
}
var replacer = strings.NewReplacer("-", "_", "=", "")
// https://stackoverflow.com/a/50581165
func randStr(len int) string {
buff := make([]byte, len)
_, _ = rand.Read(buff)
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
// Base 64 can be longer than len
return str[:len]
}

View File

@@ -1,53 +0,0 @@
package queue
import "github.com/elyby/chrly/api/mojang"
type UuidsStorage interface {
GetUuid(username string) (string, error)
StoreUuid(username string, uuid string) error
}
// nil value can be passed to the storage to indicate that there is no textures
// for uuid and we know about it. Return err only in case, when storage completely
// unable to load any information about textures
type TexturesStorage interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
StoreTextures(uuid string, textures *mojang.SignedTexturesResponse)
}
type Storage interface {
UuidsStorage
TexturesStorage
}
// SplittedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SplittedStorage struct {
UuidsStorage
TexturesStorage
}
func (s *SplittedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
}
func (s *SplittedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
}
func (s *SplittedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
return s.TexturesStorage.GetTextures(uuid)
}
func (s *SplittedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -1,97 +0,0 @@
package auth
import (
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
)
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8"
func TestJwtAuth_NewToken_Success(t *testing.T) {
assert := testify.New(t)
jwt := &JwtAuth{[]byte("secret")}
token, err := jwt.NewToken(SkinScope)
assert.Nil(err)
assert.NotNil(token)
}
func TestJwtAuth_NewToken_KeyNotAvailable(t *testing.T) {
assert := testify.New(t)
jwt := &JwtAuth{}
token, err := jwt.NewToken(SkinScope)
assert.Error(err, "signing key not available")
assert.Nil(token)
}
func TestJwtAuth_Check_EmptyRequest(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Authentication header not presented")
}
func TestJwtAuth_Check_NonBearer(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "this is not jwt")
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Cannot recognize JWT token in passed value")
}
func TestJwtAuth_Check_BearerButNotJwt(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt")
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Cannot parse passed JWT token")
}
func TestJwtAuth_Check_SecretNotAvailable(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{}
err := jwt.Check(req)
assert.Error(err, "Signing key not set")
}
func TestJwtAuth_Check_SecretInvalid(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{[]byte("this is another secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "JWT token have invalid signature. It may be corrupted or expired.")
}
func TestJwtAuth_Check_Valid(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.Nil(err)
}

View File

@@ -1,64 +0,0 @@
package bootstrap
import (
"os"
"github.com/getsentry/raven-go"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
"github.com/mono83/slf/recievers/statsd"
"github.com/mono83/slf/recievers/writer"
"github.com/mono83/slf/wd"
)
var version = ""
func GetVersion() string {
return version
}
func CreateLogger(statsdAddr string, sentryAddr string) (wd.Watchdog, error) {
wd.AddReceiver(writer.New(writer.Options{
Marker: false,
TimeFormat: "15:04:05.000",
}))
if statsdAddr != "" {
hostname, _ := os.Hostname()
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
wd.AddReceiver(statsdReceiver)
}
if sentryAddr != "" {
ravenClient, err := raven.New(sentryAddr)
if err != nil {
return nil, err
}
ravenClient.SetEnvironment("production")
ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver")
programVersion := GetVersion()
if programVersion != "" {
raven.SetRelease(programVersion)
}
sentryReceiver, err := sentry.NewReceiverWithCustomRaven(ravenClient, &sentry.Config{
MinLevel: "warn",
})
if err != nil {
return nil, err
}
wd.AddReceiver(sentryReceiver)
}
return wd.New("", "").WithParams(rays.Host), nil
}

View File

@@ -2,19 +2,23 @@ package cmd
import (
"fmt"
"log"
"os"
"strings"
"github.com/elyby/chrly/bootstrap"
. "github.com/goava/di"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/di"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/version"
)
var RootCmd = &cobra.Command{
Use: "chrly",
Short: "Implementation of Minecraft skins system server",
Version: bootstrap.GetVersion(),
Version: version.Version(),
}
// Execute adds all child commands to the root command and sets flags appropriately.
@@ -26,6 +30,32 @@ func Execute() {
}
}
func shouldGetContainer() *Container {
container, err := di.New()
if err != nil {
panic(err)
}
return container
}
func startServer(modules []string) {
container := shouldGetContainer()
var config *viper.Viper
err := container.Resolve(&config)
if err != nil {
log.Fatal(err)
}
config.Set("modules", modules)
err = container.Invoke(http.StartServer)
if err != nil {
log.Fatal(err)
}
}
func init() {
cobra.OnInitialize(initConfig)
}

View File

@@ -1,87 +1,17 @@
package cmd
import (
"fmt"
"log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/http"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Starts http handler for the skins system",
Short: "Starts HTTP handler for the skins system",
Run: func(cmd *cobra.Command, args []string) {
logger, err := bootstrap.CreateLogger(viper.GetString("statsd.addr"), viper.GetString("sentry.dsn"))
if err != nil {
log.Fatal(fmt.Printf("Cannot initialize logger: %v", err))
}
logger.Info("Logger successfully initialized")
storageFactory := db.StorageFactory{Config: viper.GetViper()}
logger.Info("Initializing skins repository")
redisFactory := storageFactory.CreateFactory("redis")
skinsRepo, err := redisFactory.CreateSkinsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err))
return
}
logger.Info("Skins repository successfully initialized")
logger.Info("Initializing capes repository")
filesystemFactory := storageFactory.CreateFactory("filesystem")
capesRepo, err := filesystemFactory.CreateCapesRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err))
return
}
logger.Info("Capes repository successfully initialized")
logger.Info("Preparing Mojang's textures queue")
mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating mojang uuids repo: %v", err))
return
}
mojangTexturesQueue := &queue.JobsQueue{
Logger: logger,
Storage: &queue.SplittedStorage{
UuidsStorage: mojangUuidsRepository,
TexturesStorage: queue.CreateInMemoryTexturesStorage(),
},
}
logger.Info("Mojang's textures queue is successfully initialized")
cfg := &http.Config{
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
MojangTexturesQueue: mojangTexturesQueue,
Logger: logger,
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
}
if err := cfg.Run(); err != nil {
logger.Error(fmt.Sprintf("Error in main(): %v", err))
}
startServer([]string{"skinsystem", "api"})
},
}
func init() {
RootCmd.AddCommand(serveCmd)
viper.SetDefault("server.host", "")
viper.SetDefault("server.port", 80)
viper.SetDefault("storage.redis.host", "localhost")
viper.SetDefault("storage.redis.port", 6379)
viper.SetDefault("storage.redis.poll", 10)
viper.SetDefault("storage.filesystem.basePath", "data")
viper.SetDefault("storage.filesystem.capesDirName", "capes")
}

View File

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

View File

@@ -2,19 +2,28 @@ package cmd
import (
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
"github.com/elyby/chrly/bootstrap"
"runtime"
"github.com/elyby/chrly/version"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show the Chrly version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Version: %s\n", bootstrap.GetVersion())
fmt.Printf("Go version: %s\n", runtime.Version())
hostname, err := os.Hostname()
if err != nil {
hostname = "<unknown>"
}
fmt.Printf("Version: %s\n", version.Version())
fmt.Printf("Commit: %s\n", version.Commit())
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Printf("Hostname: %s\n", hostname)
},
}

17
cmd/worker.go Normal file
View File

@@ -0,0 +1,17 @@
package cmd
import (
"github.com/spf13/cobra"
)
var workerCmd = &cobra.Command{
Use: "worker",
Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker",
Run: func(cmd *cobra.Command, args []string) {
startServer([]string{"worker"})
},
}
func init() {
RootCmd.AddCommand(workerCmd)
}

View File

@@ -1,25 +0,0 @@
package db
type ParamRequired struct {
Param string
}
func (e ParamRequired) Error() string {
return "Required parameter not provided"
}
type SkinNotFoundError struct {
Who string
}
func (e SkinNotFoundError) Error() string {
return "Skin data not found."
}
type CapeNotFoundError struct {
Who string
}
func (e CapeNotFoundError) Error() string {
return "Cape file not found."
}

View File

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

View File

@@ -1,64 +0,0 @@
package db
import (
"os"
"path"
"strings"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
)
type FilesystemFactory struct {
BasePath string
CapesDirName string
}
func (f FilesystemFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
panic("skins repository not supported for this storage type")
}
func (f FilesystemFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
if err := f.validateFactoryConfig(); err != nil {
return nil, err
}
return &filesStorage{path: path.Join(f.BasePath, f.CapesDirName)}, nil
}
func (f FilesystemFactory) CreateMojangUuidsRepository() (queue.UuidsStorage, error) {
panic("implement me")
}
func (f FilesystemFactory) validateFactoryConfig() error {
if f.BasePath == "" {
return &ParamRequired{"basePath"}
}
if f.CapesDirName == "" {
f.CapesDirName = "capes"
}
return nil
}
type filesStorage struct {
path string
}
func (repository *filesStorage) FindByUsername(username string) (*model.Cape, error) {
if username == "" {
return nil, &CapeNotFoundError{username}
}
capePath := path.Join(repository.path, strings.ToLower(username)+".png")
file, err := os.Open(capePath)
if err != nil {
return nil, &CapeNotFoundError{username}
}
return &model.Cape{
File: file,
}, nil
}

33
db/fs/fs.go Normal file
View File

@@ -0,0 +1,33 @@
package fs
import (
"os"
"path"
"strings"
"github.com/elyby/chrly/model"
)
func New(basePath string) (*Filesystem, error) {
return &Filesystem{path: basePath}, nil
}
type Filesystem struct {
path string
}
func (f *Filesystem) FindCapeByUsername(username string) (*model.Cape, error) {
capePath := path.Join(f.path, strings.ToLower(username)+".png")
file, err := os.Open(capePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &model.Cape{
File: file,
}, nil
}

View File

@@ -0,0 +1,56 @@
package fs
import (
"fmt"
"io/ioutil"
"os"
"path"
"testing"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
fs, err := New("base/path")
require.Nil(t, err)
require.Equal(t, "base/path", fs.path)
}
func TestFilesystem(t *testing.T) {
t.Run("FindCapeByUsername", func(t *testing.T) {
dir, err := ioutil.TempDir("", "capes")
if err != nil {
panic(fmt.Errorf("cannot crete temp directory for tests: %w", err))
}
defer os.RemoveAll(dir)
t.Run("exists cape", func(t *testing.T) {
file, err := os.Create(path.Join(dir, "username.png"))
if err != nil {
panic(fmt.Errorf("cannot create temp skin for tests: %w", err))
}
defer os.Remove(file.Name())
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("username")
require.Nil(t, err)
require.NotNil(t, cape)
capeFile, _ := cape.File.(*os.File)
require.Equal(t, file.Name(), capeFile.Name())
})
t.Run("not exists cape", func(t *testing.T) {
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("username")
require.Nil(t, err)
require.Nil(t, cape)
})
t.Run("empty username", func(t *testing.T) {
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("")
require.Nil(t, err)
require.Nil(t, cape)
})
})
}

View File

@@ -1,10 +1,9 @@
package db
package redis
import (
"bytes"
"compress/zlib"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
@@ -14,69 +13,31 @@ import (
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix.v2/util"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
type RedisFactory struct {
Host string
Port int
PoolSize int
pool *pool.Pool
}
var now = time.Now
func (f *RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
return f.createInstance()
}
func (f *RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
panic("capes repository not supported for this storage type")
}
func (f *RedisFactory) CreateMojangUuidsRepository() (queue.UuidsStorage, error) {
return f.createInstance()
}
func (f *RedisFactory) createInstance() (*redisDb, error) {
p, err := f.getPool()
func New(addr string, poolSize int) (*Redis, error) {
conn, err := pool.New("tcp", addr, poolSize)
if err != nil {
return nil, err
}
return &redisDb{p}, nil
return &Redis{
pool: conn,
}, nil
}
func (f *RedisFactory) getPool() (*pool.Pool, error) {
if f.pool == nil {
if f.Host == "" {
return nil, &ParamRequired{"host"}
}
const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username"
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
if f.Port == 0 {
return nil, &ParamRequired{"port"}
}
addr := fmt.Sprintf("%s:%d", f.Host, f.Port)
conn, err := pool.New("tcp", addr, f.PoolSize)
if err != nil {
return nil, err
}
f.pool = conn
}
return f.pool, nil
}
type redisDb struct {
type Redis struct {
pool *pool.Pool
}
const accountIdToUsernameKey = "hash:username-to-account-id"
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) {
conn, err := db.pool.Get()
if err != nil {
return nil, err
@@ -86,82 +47,14 @@ func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
return findByUsername(username, conn)
}
func (db *redisDb) FindByUserId(id int) (*model.Skin, error) {
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
return findByUserId(id, conn)
}
func (db *redisDb) Save(skin *model.Skin) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return save(skin, conn)
}
func (db *redisDb) RemoveByUserId(id int) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUserId(id, conn)
}
func (db *redisDb) RemoveByUsername(username string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUsername(username, conn)
}
func (db *redisDb) GetUuid(username string) (string, error) {
conn, err := db.pool.Get()
if err != nil {
return "", err
}
defer db.pool.Put(conn)
return findMojangUuidByUsername(username, conn)
}
func (db *redisDb) StoreUuid(username string, uuid string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return storeMojangUuid(username, uuid, conn)
}
func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
if username == "" {
return nil, &SkinNotFoundError{username}
}
redisKey := buildUsernameKey(username)
response := conn.Cmd("GET", redisKey)
if !response.IsType(redis.Str) {
return nil, &SkinNotFoundError{username}
}
encodedResult, err := response.Bytes()
if err != nil {
return nil, err
if response.IsType(redis.Nil) {
return nil, nil
}
encodedResult, _ := response.Bytes()
result, err := zlibDecode(encodedResult)
if err != nil {
return nil, err
@@ -178,55 +71,38 @@ func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
return skin, nil
}
func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
return findByUserId(id, conn)
}
func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
response := conn.Cmd("HGET", accountIdToUsernameKey, id)
if !response.IsType(redis.Str) {
return nil, &SkinNotFoundError{"unknown"}
if response.IsType(redis.Nil) {
return nil, nil
}
username, _ := response.Str()
username, err := response.Str()
if err != nil {
return nil, err
}
return findByUsername(username, conn)
}
func removeByUserId(id int, conn util.Cmder) error {
record, err := findByUserId(id, conn)
func (db *Redis) SaveSkin(skin *model.Skin) error {
conn, err := db.pool.Get()
if err != nil {
if _, ok := err.(*SkinNotFoundError); !ok {
return err
}
}
conn.Cmd("MULTI")
conn.Cmd("HDEL", accountIdToUsernameKey, id)
if record != nil {
conn.Cmd("DEL", buildUsernameKey(record.Username))
}
conn.Cmd("EXEC")
return nil
}
func removeByUsername(username string, conn util.Cmder) error {
record, err := findByUsername(username, conn)
if err != nil {
if _, ok := err.(*SkinNotFoundError); ok {
return nil
}
return err
}
defer db.pool.Put(conn)
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
conn.Cmd("EXEC")
return nil
return save(skin, conn)
}
func save(skin *model.Skin, conn util.Cmder) error {
@@ -252,25 +128,103 @@ func save(skin *model.Skin, conn util.Cmder) error {
return nil
}
func (db *Redis) RemoveSkinByUserId(id int) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUserId(id, conn)
}
func removeByUserId(id int, conn util.Cmder) error {
record, err := findByUserId(id, conn)
if err != nil {
return err
}
conn.Cmd("MULTI")
conn.Cmd("HDEL", accountIdToUsernameKey, id)
if record != nil {
conn.Cmd("DEL", buildUsernameKey(record.Username))
}
conn.Cmd("EXEC")
return nil
}
func (db *Redis) RemoveSkinByUsername(username string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUsername(username, conn)
}
func removeByUsername(username string, conn util.Cmder) error {
record, err := findByUsername(username, conn)
if err != nil {
return err
}
if record == nil {
return nil
}
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
conn.Cmd("EXEC")
return nil
}
func (db *Redis) GetUuid(username string) (string, error) {
conn, err := db.pool.Get()
if err != nil {
return "", err
}
defer db.pool.Put(conn)
return findMojangUuidByUsername(username, conn)
}
func findMojangUuidByUsername(username string, conn util.Cmder) (string, error) {
response := conn.Cmd("HGET", mojangUsernameToUuidKey, strings.ToLower(username))
if response.IsType(redis.Nil) {
return "", &queue.ValueNotFound{}
return "", &mojangtextures.ValueNotFound{}
}
data, _ := response.Str()
parts := strings.Split(data, ":")
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(time.Now()) {
return "", &queue.ValueNotFound{}
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
return "", &mojangtextures.ValueNotFound{}
}
return parts[0], nil
}
func (db *Redis) StoreUuid(username string, uuid string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return storeMojangUuid(username, uuid, conn)
}
func storeMojangUuid(username string, uuid string, conn util.Cmder) error {
value := uuid + ":" + strconv.FormatInt(time.Now().Unix(), 10)
value := uuid + ":" + strconv.FormatInt(now().Unix(), 10)
res := conn.Cmd("HSET", mojangUsernameToUuidKey, strings.ToLower(username), value)
if res.IsType(redis.Err) {
return res.Err
@@ -279,6 +233,15 @@ func storeMojangUuid(username string, uuid string, conn util.Cmder) error {
return nil
}
func (db *Redis) Ping() error {
r := db.pool.Cmd("PING")
if r.Err != nil {
return r.Err
}
return nil
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}
@@ -294,14 +257,14 @@ func zlibEncode(str []byte) []byte {
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, readError := zlib.NewReader(buff)
if readError != nil {
return nil, readError
reader, err := zlib.NewReader(buff)
if err != nil {
return nil, err
}
resultBuffer := new(bytes.Buffer)
_, _ = io.Copy(resultBuffer, reader)
reader.Close()
_ = reader.Close()
return resultBuffer.Bytes(), nil
}

View File

@@ -0,0 +1,361 @@
// +build redis
package redis
import (
"fmt"
"reflect"
"testing"
"time"
"github.com/mediocregopher/radix.v2/redis"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
const redisAddr = "localhost:6379"
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
internalPool := reflect.ValueOf(conn.pool).Elem().FieldByName("pool")
assert.Equal(t, 12, internalPool.Cap())
})
t.Run("should return error", func(t *testing.T) {
conn, err := New("localhost:12345", 12) // Use localhost to avoid DNS resolution
assert.Error(t, err)
assert.Nil(t, conn)
})
}
type redisTestSuite struct {
suite.Suite
Redis *Redis
cmd func(cmd string, args ...interface{}) *redis.Resp
}
func (suite *redisTestSuite) SetupSuite() {
conn, err := New(redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
suite.Redis = conn
suite.cmd = conn.pool.Cmd
}
func (suite *redisTestSuite) SetupTest() {
// Cleanup database before the each test
suite.cmd("FLUSHALL")
}
func (suite *redisTestSuite) TearDownTest() {
// Restore time.Now func
now = time.Now
}
func (suite *redisTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
}
func TestRedis(t *testing.T) {
suite.Run(t, new(redisTestSuite))
}
/**
* JSON with zlib encoding
* {
* userId: 1,
* uuid: "fd5da1e4d66d4d17aadee2446093896d",
* username: "Mock",
* skinId: 1,
* url: "http://localhost/skin.png",
* is1_8: true,
* isSlim: false,
* mojangTextures: "mock-mojang-textures",
* mojangSignature: "mock-mojang-signature"
* }
*/
var skinRecord = []byte{
0x78, 0x9c, 0x5c, 0xce, 0x4b, 0x4a, 0x4, 0x41, 0xc, 0xc6, 0xf1, 0xbb, 0x7c, 0xeb, 0x1a, 0xdb, 0xd6, 0xb2,
0x9c, 0xc9, 0xd, 0x5c, 0x88, 0x8b, 0xd1, 0xb5, 0x84, 0x4e, 0xa6, 0xa7, 0xec, 0x7a, 0xc, 0xf5, 0x0, 0x41,
0xbc, 0xbb, 0xb4, 0xd2, 0xa, 0x2e, 0xf3, 0xe3, 0x9f, 0x90, 0xf, 0xf4, 0xaa, 0xe5, 0x41, 0x40, 0xa3, 0x41,
0xef, 0x5e, 0x40, 0x38, 0xc9, 0x9d, 0xf0, 0xa8, 0x56, 0x9c, 0x13, 0x2b, 0xe3, 0x3d, 0xb3, 0xa8, 0xde, 0x58,
0xeb, 0xae, 0xf, 0xb7, 0xfb, 0x83, 0x13, 0x98, 0xef, 0xa5, 0xc4, 0x51, 0x41, 0x78, 0xcc, 0xd3, 0x2, 0x83,
0xba, 0xf8, 0xb4, 0x9d, 0x29, 0x1, 0x84, 0x73, 0x6b, 0x17, 0x1a, 0x86, 0x90, 0x27, 0xe, 0xe7, 0x5c, 0xdb,
0xb0, 0x16, 0x57, 0x97, 0x34, 0xc3, 0xc0, 0xd7, 0xf1, 0x75, 0xf, 0x6a, 0xa5, 0xeb, 0x3a, 0x1c, 0x83, 0x8f,
0xa0, 0x13, 0x87, 0xaa, 0x6, 0x31, 0xbf, 0x71, 0x9a, 0x9f, 0xf5, 0xbd, 0xf5, 0xa2, 0x15, 0x84, 0x98, 0xa7,
0x65, 0xf7, 0xa3, 0xbb, 0xb6, 0xf1, 0xd6, 0x1d, 0xfd, 0x9c, 0x78, 0xa5, 0x7f, 0x61, 0xfd, 0x75, 0x83, 0xa7,
0x20, 0x2f, 0x7f, 0xff, 0xe2, 0xf3, 0x2b, 0x0, 0x0, 0xff, 0xff, 0x6f, 0xdd, 0x51, 0x71,
}
func (suite *redisTestSuite) TestFindSkinByUsername() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(err)
suite.Require().NotNil(skin)
suite.Require().Equal(1, skin.UserId)
suite.Require().Equal("fd5da1e4d66d4d17aadee2446093896d", skin.Uuid)
suite.Require().Equal("Mock", skin.Username)
suite.Require().Equal(1, skin.SkinId)
suite.Require().Equal("http://localhost/skin.png", skin.Url)
suite.Require().True(skin.Is1_8)
suite.Require().False(skin.IsSlim)
suite.Require().Equal("mock-mojang-textures", skin.MojangTextures)
suite.Require().Equal("mock-mojang-signature", skin.MojangSignature)
suite.Require().Equal(skin.Username, skin.OldUsername)
})
suite.RunSubTest("not exists record", func() {
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
suite.RunSubTest("invalid zlib encoding", func() {
suite.cmd("SET", "username:mock", "this is really not zlib")
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(skin)
suite.Require().EqualError(err, "zlib: invalid header")
})
suite.RunSubTest("invalid json encoding", func() {
suite.cmd("SET", "username:mock", []byte{
0x78, 0x9c, 0xca, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x28, 0xcf, 0x2f, 0xca, 0x49, 0x1, 0x4, 0x0, 0x0, 0xff,
0xff, 0x1a, 0xb, 0x4, 0x5d,
})
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(skin)
suite.Require().EqualError(err, "invalid character 'h' looking for beginning of value")
})
}
func (suite *redisTestSuite) TestFindSkinByUserId() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().NotNil(skin)
suite.Require().Equal(1, skin.UserId)
})
suite.RunSubTest("not exists record", func() {
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
suite.RunSubTest("exists hash record, but no skin record", func() {
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
}
func (suite *redisTestSuite) TestSaveSkin() {
suite.RunSubTest("save new entity", func() {
err := suite.Redis.SaveSkin(&model.Skin{
UserId: 1,
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
Username: "Mock",
SkinId: 1,
Url: "http://localhost/skin.png",
Is1_8: true,
IsSlim: false,
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
})
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().False(usernameResp.IsType(redis.Nil))
bytes, _ := usernameResp.Bytes()
suite.Require().Equal(skinRecord, bytes)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().False(usernameResp.IsType(redis.Nil))
str, _ := idResp.Str()
suite.Require().Equal("Mock", str)
})
suite.RunSubTest("save exists record with changed username", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.SaveSkin(&model.Skin{
UserId: 1,
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
Username: "NewMock",
SkinId: 1,
Url: "http://localhost/skin.png",
Is1_8: true,
IsSlim: false,
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
OldUsername: "Mock",
})
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:newmock")
suite.Require().False(usernameResp.IsType(redis.Nil))
bytes, _ := usernameResp.Bytes()
suite.Require().Equal([]byte{
0x78, 0x9c, 0x5c, 0x8e, 0xcb, 0x4e, 0xc3, 0x40, 0xc, 0x45, 0xff, 0xe5, 0xae, 0xa7, 0x84, 0x40, 0x18, 0x5a,
0xff, 0x1, 0xb, 0x60, 0x51, 0x58, 0x23, 0x2b, 0x76, 0xd3, 0x21, 0xf3, 0xa8, 0xe6, 0x21, 0x90, 0x10, 0xff,
0x8e, 0x52, 0x14, 0x90, 0xba, 0xf4, 0xd1, 0xf1, 0xd5, 0xf9, 0x42, 0x2b, 0x9a, 0x1f, 0x4, 0xd4, 0x1b, 0xb4,
0xe6, 0x4, 0x84, 0x83, 0xdc, 0x9, 0xf7, 0x3a, 0x88, 0xb5, 0x32, 0x48, 0x7f, 0xcf, 0x2c, 0xaa, 0x37, 0xc3,
0x60, 0xaf, 0x77, 0xb7, 0xdb, 0x9d, 0x15, 0x98, 0xf3, 0x53, 0xe4, 0xa0, 0x20, 0x3c, 0xe9, 0xc7, 0x63, 0x1a,
0x67, 0x18, 0x94, 0xd9, 0xc5, 0x75, 0x29, 0x7b, 0x10, 0x8e, 0xb5, 0x9e, 0xa8, 0xeb, 0x7c, 0x1a, 0xd9, 0x1f,
0x53, 0xa9, 0xdd, 0x62, 0x5c, 0x9d, 0xe2, 0x4, 0x3, 0x57, 0xfa, 0xb7, 0x2d, 0xa8, 0xe6, 0xa6, 0xcb, 0xb1,
0xf7, 0x2e, 0x80, 0xe, 0xec, 0x8b, 0x1a, 0x84, 0xf4, 0xce, 0x71, 0x7a, 0xd1, 0xcf, 0xda, 0xb2, 0x16, 0x10,
0x42, 0x1a, 0xe7, 0xcd, 0x2f, 0xdd, 0xd4, 0x15, 0xaf, 0xde, 0xde, 0x4d, 0x91, 0x17, 0x74, 0x21, 0x96, 0x3f,
0x6e, 0xf0, 0xec, 0xe5, 0xf5, 0x3f, 0xf9, 0xdc, 0xfb, 0xfd, 0x13, 0x0, 0x0, 0xff, 0xff, 0xca, 0xc3, 0x54,
0x25,
}, bytes)
oldUsernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(oldUsernameResp.IsType(redis.Nil))
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().False(usernameResp.IsType(redis.Nil))
str, _ := idResp.Str()
suite.Require().Equal("NewMock", str)
})
}
func (suite *redisTestSuite) TestRemoveSkinByUserId() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
})
suite.RunSubTest("exists only id", func() {
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().Nil(err)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
})
suite.RunSubTest("error when querying skin record", func() {
suite.cmd("SET", "username:mock", "invalid zlib")
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().EqualError(err, "zlib: invalid header")
})
}
func (suite *redisTestSuite) TestRemoveSkinByUsername() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
})
suite.RunSubTest("exists only username", func() {
suite.cmd("SET", "username:mock", skinRecord)
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
})
suite.RunSubTest("no records", func() {
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
})
suite.RunSubTest("error when querying skin record", func() {
suite.cmd("SET", "username:mock", "invalid zlib")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().EqualError(err, "zlib: invalid header")
})
}
func (suite *redisTestSuite) TestGetUuid() {
suite.RunSubTest("exists record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("not exists record", func() {
uuid, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
})
suite.RunSubTest("exists, but expired record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
})
}
func (suite *redisTestSuite) TestStoreUuid() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().False(resp.IsType(redis.Nil))
str, _ := resp.Str()
suite.Require().Equal(str, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
}
func (suite *redisTestSuite) TestPing() {
err := suite.Redis.Ping()
suite.Require().Nil(err)
}

14
di/config.go Normal file
View File

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

73
di/db.go Normal file
View File

@@ -0,0 +1,73 @@
package di
import (
"fmt"
"path"
"github.com/goava/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/db/fs"
"github.com/elyby/chrly/db/redis"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
// v4 had the idea that it would be possible to separate backends for storing skins and capes.
// But in v5 the storage will be unified, so this is just temporary constructors before large reworking.
//
// Since there are no options for selecting target backends,
// all constants in this case point to static specific implementations.
var db = di.Options(
di.Provide(newRedis,
di.As(new(http.SkinsRepository)),
di.As(new(mojangtextures.UuidsStorage)),
),
di.Provide(newFSFactory,
di.As(new(http.CapesRepository)),
),
di.Provide(newMojangSignedTexturesStorage),
)
func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) {
config.SetDefault("storage.redis.host", "localhost")
config.SetDefault("storage.redis.port", 6379)
config.SetDefault("storage.redis.poll", 10)
conn, err := redis.New(
fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")),
config.GetInt("storage.redis.poolSize"),
)
if err != nil {
return nil, err
}
if err := container.Provide(func() *namedHealthChecker {
return &namedHealthChecker{
Name: "redis",
Checker: es.DatabaseChecker(conn),
}
}); err != nil {
return nil, err
}
return conn, nil
}
func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) {
config.SetDefault("storage.filesystem.basePath", "data")
config.SetDefault("storage.filesystem.capesDirName", "capes")
return fs.New(path.Join(
config.GetString("storage.filesystem.basePath"),
config.GetString("storage.filesystem.capesDirName"),
))
}
func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage {
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
return texturesStorage
}

20
di/di.go Normal file
View File

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

36
di/dispatcher.go Normal file
View File

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

151
di/handlers.go Normal file
View File

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

97
di/logger.go Normal file
View File

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

148
di/mojang_textures.go Normal file
View File

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

65
di/server.go Normal file
View File

@@ -0,0 +1,65 @@
package di
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"github.com/spf13/viper"
. "github.com/elyby/chrly/http"
)
var server = di.Options(
di.Provide(newAuthenticator, di.As(new(Authenticator))),
di.Provide(newServer),
)
func newAuthenticator(config *viper.Viper, emitter Emitter) (*JwtAuth, error) {
key := config.GetString("chrly.secret")
if key == "" {
return nil, errors.New("chrly.secret must be set in order to use authenticator")
}
return &JwtAuth{
Key: []byte(key),
Emitter: emitter,
}, nil
}
type serverParams struct {
di.Inject
Config *viper.Viper `di:""`
Handler http.Handler `di:""`
Sentry *raven.Client `di:"" optional:"true"`
}
func newServer(params serverParams) *http.Server {
params.Config.SetDefault("server.host", "")
params.Config.SetDefault("server.port", 80)
handler := params.Handler
if params.Sentry != nil {
// raven.Recoverer uses DefaultClient and nothing can be done about it
// To avoid code duplication, if the Sentry service is successfully initiated,
// it will also replace DefaultClient, so raven.Recoverer will work with the instance
// created in the application constructor
handler = raven.Recoverer(handler)
}
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))
server := &http.Server{
Addr: address,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
return server
}

34
dispatcher/dispatcher.go Normal file
View File

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

View File

@@ -20,6 +20,15 @@ services:
environment:
CHRLY_SECRET: replace_this_value_in_production
# Use this configuration in case when you need a remote Mojang UUIDs provider
# worker:
# image: elyby/chrly
# hostname: chrly0
# restart: always
# ports:
# - "8080:80"
# command: ["worker"]
redis:
image: redis:4.0-32bit # 32-bit version is recommended to spare some memory
restart: always

View File

@@ -5,7 +5,7 @@ if [ ! -d /data/capes ]; then
mkdir -p /data/capes
fi
if [ "$1" = "serve" ] || [ "$1" = "token" ] || [ "$1" = "version" ]; then
if [ "$1" = "serve" ] || [ "$1" = "worker" ] || [ "$1" = "token" ] || [ "$1" = "version" ]; then
set -- /usr/local/bin/chrly "$@"
fi

View File

@@ -0,0 +1,84 @@
package eventsubscribers
import (
"context"
"errors"
"sync"
"time"
"github.com/etherlabsio/healthcheck"
"github.com/elyby/chrly/api/mojang"
)
type Pingable interface {
Ping() error
}
func DatabaseChecker(connection Pingable) healthcheck.CheckerFunc {
return func(ctx context.Context) error {
done := make(chan error)
go func() {
done <- connection.Ping()
}()
select {
case <-ctx.Done():
return errors.New("check timeout")
case err := <-done:
return err
}
}
}
func MojangBatchUuidsProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
var mutex sync.Mutex
var lastCallErr error
var expireTimer *time.Timer
dispatcher.Subscribe(
"mojang_textures:batch_uuids_provider:result",
func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
mutex.Lock()
defer mutex.Unlock()
lastCallErr = err
if expireTimer != nil {
expireTimer.Stop()
}
expireTimer = time.AfterFunc(resetDuration, func() {
mutex.Lock()
lastCallErr = nil
mutex.Unlock()
})
},
)
return func(ctx context.Context) error {
mutex.Lock()
defer mutex.Unlock()
return lastCallErr
}
}
func MojangBatchUuidsProviderQueueLengthChecker(dispatcher Subscriber, maxLength int) healthcheck.CheckerFunc {
var mutex sync.Mutex
queueLength := 0
dispatcher.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, tasksInQueue int) {
mutex.Lock()
queueLength = tasksInQueue
mutex.Unlock()
})
return func(ctx context.Context) error {
mutex.Lock()
defer mutex.Unlock()
if queueLength < maxLength {
return nil
}
return errors.New("the maximum number of tasks in the queue has been exceeded")
}
}

View File

@@ -0,0 +1,109 @@
package eventsubscribers
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type pingableMock struct {
mock.Mock
}
func (p *pingableMock) Ping() error {
args := p.Called()
return args.Error(0)
}
func TestDatabaseChecker(t *testing.T) {
t.Run("no error", func(t *testing.T) {
p := &pingableMock{}
p.On("Ping").Return(nil)
checker := DatabaseChecker(p)
assert.Nil(t, checker(context.Background()))
})
t.Run("with error", func(t *testing.T) {
err := errors.New("mock error")
p := &pingableMock{}
p.On("Ping").Return(err)
checker := DatabaseChecker(p)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("context timeout", func(t *testing.T) {
p := &pingableMock{}
waitChan := make(chan time.Time, 1)
p.On("Ping").WaitUntil(waitChan).Return(nil)
ctx, _ := context.WithTimeout(context.Background(), 0)
checker := DatabaseChecker(p)
assert.Errorf(t, checker(ctx), "check timeout")
close(waitChan)
})
}
func TestMojangBatchUuidsProviderChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
//
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil)
assert.Nil(t, checker(context.Background()))
})
t.Run("when error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("should reset value after passed duration", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
}
func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
assert.Nil(t, checker(context.Background()))
})
t.Run("less than allowed limit", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9)
assert.Nil(t, checker(context.Background()))
})
t.Run("greater than allowed limit", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10)
checkResult := checker(context.Background())
if assert.Error(t, checkResult) {
assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error())
}
})
}

View File

@@ -0,0 +1,94 @@
package eventsubscribers
import (
"net"
"net/http"
"net/url"
"strings"
"syscall"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
type Logger struct {
slf.Logger
}
func (l *Logger) ConfigureWithDispatcher(d Subscriber) {
d.Subscribe("skinsystem:after_request", l.handleAfterSkinsystemRequest)
d.Subscribe("mojang_textures:usernames:after_call", l.createMojangTexturesErrorHandler("usernames"))
d.Subscribe("mojang_textures:textures:after_call", l.createMojangTexturesErrorHandler("textures"))
}
func (l *Logger) handleAfterSkinsystemRequest(req *http.Request, statusCode int) {
path := req.URL.Path
if req.URL.RawQuery != "" {
path += "?" + req.URL.RawQuery
}
l.Info(
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
wd.StringParam("ip", trimPort(req.RemoteAddr)),
wd.StringParam("method", req.Method),
wd.StringParam("path", path),
wd.IntParam("statusCode", statusCode),
wd.StringParam("userAgent", req.UserAgent()),
wd.StringParam("forwardedIp", req.Header.Get("X-Forwarded-For")),
)
}
func (l *Logger) createMojangTexturesErrorHandler(provider string) func(identity string, result interface{}, err error) {
providerParam := wd.NameParam(provider)
return func(identity string, result interface{}, err error) {
if err == nil {
return
}
errParam := wd.ErrParam(err)
switch err.(type) {
case *mojang.BadRequestError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case *mojang.ForbiddenError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case *mojang.TooManyRequestsError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case net.Error:
if err.(net.Error).Timeout() {
return
}
if _, ok := err.(*url.Error); ok {
return
}
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
return
}
if err == syscall.ECONNREFUSED {
return
}
}
l.Error(":name: Unexpected Mojang response error: :err", providerParam, errParam)
}
}
func (l *Logger) logMojangTexturesWarning(providerParam slf.Param, errParam slf.Param) {
l.Warning(":name: :err", providerParam, errParam)
}
func trimPort(ip string) string {
// Don't care about possible -1 result because RemoteAddr will always contain ip and port
cutTo := strings.LastIndexByte(ip, ':')
return ip[0:cutTo]
}

View File

@@ -0,0 +1,256 @@
package eventsubscribers
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"syscall"
"testing"
"github.com/mono83/slf"
"github.com/mono83/slf/params"
"github.com/stretchr/testify/mock"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type LoggerMock struct {
mock.Mock
}
func prepareLoggerArgs(message string, params []slf.Param) []interface{} {
args := []interface{}{message}
for _, v := range params {
args = append(args, v.(interface{}))
}
return args
}
func (l *LoggerMock) Trace(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Debug(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Info(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Warning(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Error(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Alert(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Emergency(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
type LoggerTestCase struct {
Events [][]interface{}
ExpectedCalls [][]interface{}
}
var loggerTestCases = map[string]*LoggerTestCase{
"should log each request to the skinsystem": {
Events: [][]interface{}{
{"skinsystem:after_request",
(func() *http.Request {
req := httptest.NewRequest("GET", "http://localhost/skins/username.png", nil)
req.Header.Add("User-Agent", "Test user agent")
return req
})(),
201,
},
},
ExpectedCalls: [][]interface{}{
{"Info",
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "ip" && strParam.Value == "192.0.2.1"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "method" && strParam.Value == "GET"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "path" && strParam.Value == "/skins/username.png"
}),
mock.MatchedBy(func(strParam params.Int) bool {
return strParam.Key == "statusCode" && strParam.Value == 201
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "userAgent" && strParam.Value == "Test user agent"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "forwardedIp" && strParam.Value == ""
}),
},
},
},
"should log each request to the skinsystem 2": {
Events: [][]interface{}{
{"skinsystem:after_request",
(func() *http.Request {
req := httptest.NewRequest("GET", "http://localhost/skins/username.png?authlib=1.5.2", nil)
req.Header.Add("User-Agent", "Test user agent")
req.Header.Add("X-Forwarded-For", "1.2.3.4")
return req
})(),
201,
},
},
ExpectedCalls: [][]interface{}{
{"Info",
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
mock.Anything, // Already tested
mock.Anything, // Already tested
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "path" && strParam.Value == "/skins/username.png?authlib=1.5.2"
}),
mock.Anything, // Already tested
mock.Anything, // Already tested
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "forwardedIp" && strParam.Value == "1.2.3.4"
}),
},
},
},
}
type timeoutError struct{}
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
func init() {
// mojang_textures providers errors
for _, providerName := range []string{"usernames", "textures"} {
pn := providerName // Store pointer to iteration value
loggerTestCases["should not log when no error occurred for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, &mojang.ProfileInfo{}, nil},
},
ExpectedCalls: nil,
}
loggerTestCases["should not log when some network errors occured for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &timeoutError{}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &url.Error{Op: "GET", URL: "http://localhost"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "read"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "dial"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, syscall.ECONNREFUSED},
},
ExpectedCalls: nil,
}
loggerTestCases["should log expected mojang errors for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.BadRequestError{
ErrorType: "IllegalArgumentException",
Message: "profileName can not be null or empty.",
}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ForbiddenError{}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.TooManyRequestsError{}},
},
ExpectedCalls: [][]interface{}{
{"Warning",
":name: :err",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "name" && strParam.Value == pn
}),
mock.MatchedBy(func(errParam params.Error) bool {
if errParam.Key != "err" {
return false
}
if _, ok := errParam.Value.(*mojang.BadRequestError); ok {
return true
}
if _, ok := errParam.Value.(*mojang.ForbiddenError); ok {
return true
}
if _, ok := errParam.Value.(*mojang.TooManyRequestsError); ok {
return true
}
return false
}),
},
},
}
loggerTestCases["should call error when unexpected error occurred for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ServerError{Status: 500}},
},
ExpectedCalls: [][]interface{}{
{"Error",
":name: Unexpected Mojang response error: :err",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "name" && strParam.Value == pn
}),
mock.MatchedBy(func(errParam params.Error) bool {
if errParam.Key != "err" {
return false
}
if _, ok := errParam.Value.(*mojang.ServerError); !ok {
return false
}
return true
}),
},
},
}
}
}
func TestLogger(t *testing.T) {
for name, c := range loggerTestCases {
t.Run(name, func(t *testing.T) {
loggerMock := &LoggerMock{}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
loggerMock.On(topicName, c[1:]...)
}
}
reporter := &Logger{
Logger: loggerMock,
}
d := dispatcher.New()
reporter.ConfigureWithDispatcher(d)
for _, args := range c.Events {
eventName, _ := args[0].(string)
d.Emit(eventName, args[1:]...)
}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
loggerMock.AssertCalled(t, topicName, c[1:]...)
}
}
})
}
}

View File

@@ -0,0 +1,177 @@
package eventsubscribers
import (
"net/http"
"strings"
"sync"
"time"
"github.com/mono83/slf"
"github.com/elyby/chrly/api/mojang"
)
type StatsReporter struct {
slf.StatsReporter
Prefix string
timersMap map[string]time.Time
timersMutex sync.Mutex
}
func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
s.timersMap = make(map[string]time.Time)
// Per request events
d.Subscribe("skinsystem:before_request", s.handleBeforeRequest)
d.Subscribe("skinsystem:after_request", s.handleAfterRequest)
// Authentication events
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.success"))
d.Subscribe("authentication:error", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
d.Subscribe("authentication:error", s.incCounterHandler("authentication.failed"))
// Mojang signed textures source events
d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request"))
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, err error) {
if err != nil {
return
}
if uuid == "" {
s.IncCounter("mojang_textures:usernames:cache_hit_nil", 1)
} else {
s.IncCounter("mojang_textures:usernames:cache_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
if err != nil {
return
}
if textures != nil {
s.IncCounter("mojang_textures.textures.cache_hit", 1)
}
})
d.Subscribe("mojang_textures:already_processing", s.incCounterHandler("mojang_textures.already_scheduled"))
d.Subscribe("mojang_textures:usernames:after_call", func(username string, profile *mojang.ProfileInfo, err error) {
if err != nil {
return
}
if profile == nil {
s.IncCounter("mojang_textures.usernames.uuid_miss", 1)
} else {
s.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:before_call", s.incCounterHandler("mojang_textures.textures.request"))
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
if err != nil {
return
}
if textures == nil {
s.IncCounter("mojang_textures.usernames.textures_miss", 1)
} else {
s.IncCounter("mojang_textures.usernames.textures_hit", 1)
}
})
d.Subscribe("mojang_textures:before_result", func(username string, uuid string) {
s.startTimeRecording("mojang_textures_result_time_" + username)
})
d.Subscribe("mojang_textures:after_result", func(username string, textures *mojang.SignedTexturesResponse, err error) {
s.finalizeTimeRecording("mojang_textures_result_time_"+username, "mojang_textures.result_time")
})
d.Subscribe("mojang_textures:textures:before_call", func(uuid string) {
s.startTimeRecording("mojang_textures_provider_time_" + uuid)
})
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
s.finalizeTimeRecording("mojang_textures_provider_time_"+uuid, "mojang_textures.textures.request_time")
})
// Mojang UUIDs batch provider metrics
d.Subscribe("mojang_textures:batch_uuids_provider:queued", s.incCounterHandler("mojang_textures.usernames.queued"))
d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) {
s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames)))
s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize))
})
d.Subscribe("mojang_textures:batch_uuids_provider:before_round", func() {
s.startTimeRecording("batch_uuids_provider_round_time")
})
d.Subscribe("mojang_textures:batch_uuids_provider:after_round", func() {
s.finalizeTimeRecording("batch_uuids_provider_round_time", "mojang_textures.usernames.round_time")
})
}
func (s *StatsReporter) handleBeforeRequest(req *http.Request) {
var key string
m := req.Method
p := req.URL.Path
if p == "/skins" {
key = "skins.get_request"
} else if strings.HasPrefix(p, "/skins/") {
key = "skins.request"
} else if p == "/cloaks" {
key = "capes.get_request"
} else if strings.HasPrefix(p, "/cloaks/") {
key = "capes.request"
} else if strings.HasPrefix(p, "/textures/signed/") {
key = "signed_textures.request"
} else if strings.HasPrefix(p, "/textures/") {
key = "textures.request"
} else if m == http.MethodPost && p == "/api/skins" {
key = "api.skins.post.request"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") {
key = "api.skins.delete.request"
} else {
return
}
s.IncCounter(key, 1)
}
func (s *StatsReporter) handleAfterRequest(req *http.Request, code int) {
var key string
m := req.Method
p := req.URL.Path
if m == http.MethodPost && p == "/api/skins" && code == http.StatusCreated {
key = "api.skins.post.success"
} else if m == http.MethodPost && p == "/api/skins" && code == http.StatusBadRequest {
key = "api.skins.post.validation_failed"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNoContent {
key = "api.skins.delete.success"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNotFound {
key = "api.skins.delete.not_found"
} else {
return
}
s.IncCounter(key, 1)
}
func (s *StatsReporter) incCounterHandler(name string) func(...interface{}) {
return func(...interface{}) {
s.IncCounter(name, 1)
}
}
func (s *StatsReporter) startTimeRecording(timeKey string) {
s.timersMutex.Lock()
defer s.timersMutex.Unlock()
s.timersMap[timeKey] = time.Now()
}
func (s *StatsReporter) finalizeTimeRecording(timeKey string, statName string) {
s.timersMutex.Lock()
defer s.timersMutex.Unlock()
startedAt, ok := s.timersMap[timeKey]
if !ok {
return
}
delete(s.timersMap, timeKey)
s.RecordTimer(statName, time.Since(startedAt))
}

View File

@@ -0,0 +1,383 @@
package eventsubscribers
import (
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/mono83/slf"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
"github.com/stretchr/testify/mock"
)
func prepareStatsReporterArgs(name string, value interface{}, params []slf.Param) []interface{} {
args := []interface{}{name, value}
for _, v := range params {
args = append(args, v.(interface{}))
}
return args
}
type StatsReporterMock struct {
mock.Mock
}
func (r *StatsReporterMock) IncCounter(name string, value int64, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, value, params)...)
}
func (r *StatsReporterMock) UpdateGauge(name string, value int64, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, value, params)...)
}
func (r *StatsReporterMock) RecordTimer(name string, duration time.Duration, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, duration, params)...)
}
func (r *StatsReporterMock) Timer(name string, params ...slf.Param) slf.Timer {
return slf.NewTimer(name, params, r)
}
type StatsReporterTestCase struct {
Events [][]interface{}
ExpectedCalls [][]interface{}
}
var statsReporterTestCases = []*StatsReporterTestCase{
// Before request
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/skins/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "skins.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/skins?name=username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "skins.get_request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/cloaks/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "capes.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/cloaks?name=username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "capes.get_request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/textures/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/textures/signed/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "signed_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/unknown", nil)},
},
ExpectedCalls: nil,
},
// After request
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil), 201},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil), 400},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.validation_failed", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil), 204},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil), 404},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.not_found", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil), 204},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil), 404},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.not_found", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/unknown", nil), 404},
},
ExpectedCalls: nil,
},
// Authenticator
{
Events: [][]interface{}{
{"authenticator:success"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "authentication.challenge", int64(1)},
{"IncCounter", "authentication.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"authentication:error", errors.New("error")},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "authentication.challenge", int64(1)},
{"IncCounter", "authentication.failed", int64(1)},
},
},
// Mojang signed textures provider
{
Events: [][]interface{}{
{"mojang_textures:call", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit_nil", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.textures.cache_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:already_processing", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.already_scheduled", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", nil, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", &mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.textures_miss", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:before_result", "username", ""},
{"mojang_textures:after_result", "username", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.result_time", mock.AnythingOfType("time.Duration")},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:before_call", "аааааааааааааааааааааааааааааааа"},
{"mojang_textures:textures:after_call", "аааааааааааааааааааааааааааааааа", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.textures.request", int64(1)},
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
{"RecordTimer", "mojang_textures.textures.request_time", mock.AnythingOfType("time.Duration")},
},
},
// Batch UUIDs provider
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:queued", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.queued", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 5},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:before_round"},
{"mojang_textures:batch_uuids_provider:after_round"},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
},
},
}
func TestStatsReporter(t *testing.T) {
for _, c := range statsReporterTestCases {
t.Run("handle events", func(t *testing.T) {
statsReporterMock := &StatsReporterMock{}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
statsReporterMock.On(topicName, c[1:]...).Once()
}
}
reporter := &StatsReporter{
StatsReporter: statsReporterMock,
Prefix: "mock_prefix",
}
d := dispatcher.New()
reporter.ConfigureWithDispatcher(d)
for _, e := range c.Events {
eventName, _ := e[0].(string)
d.Emit(eventName, e[1:]...)
}
statsReporterMock.AssertExpectations(t)
})
}
}

View File

@@ -0,0 +1,7 @@
package eventsubscribers
import "github.com/elyby/chrly/dispatcher"
type Subscriber interface {
dispatcher.Subscriber
}

View File

@@ -1,21 +1,16 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
"github.com/gorilla/mux"
"github.com/mono83/slf/wd"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/model"
)
//noinspection GoSnakeCaseUsage
@@ -47,11 +42,23 @@ func init() {
})
}
func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.post.request", 1)
type Api struct {
Emitter
SkinsRepo SkinsRepository
}
func (ctx *Api) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost)
router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete)
router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete)
return router
}
func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
cfg.Logger.IncCounter("api.skins.post.validation_failed", 1)
apiBadRequest(resp, validationErrors)
return
}
@@ -59,13 +66,20 @@ func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
username := req.Form.Get("username")
record, err := findIdentity(cfg.SkinsRepo, identityId, username)
record, err := ctx.findIdentityOrCleanup(identityId, username)
if err != nil {
cfg.Logger.Error("Error on requesting a skin from the repository: :err", wd.ErrParam(err))
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err))
apiServerError(resp)
return
}
if record == nil {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
@@ -78,76 +92,85 @@ func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
record.MojangTextures = req.Form.Get("mojangTextures")
record.MojangSignature = req.Form.Get("mojangSignature")
err = cfg.SkinsRepo.Save(record)
err = ctx.SkinsRepo.SaveSkin(record)
if err != nil {
cfg.Logger.Error("Unable to save record to the repository: :err", wd.ErrParam(err))
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
}
cfg.Logger.IncCounter("api.skins.post.success", 1)
resp.WriteHeader(http.StatusCreated)
}
func (cfg *Config) DeleteSkinByUserId(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.delete.request", 1)
func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := cfg.SkinsRepo.FindByUserId(id)
if err != nil {
cfg.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested user id")
return
}
cfg.deleteSkin(skin, resp)
skin, err := ctx.SkinsRepo.FindSkinByUserId(id)
ctx.deleteSkin(skin, err, resp)
}
func (cfg *Config) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.delete.request", 1)
func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) {
username := mux.Vars(req)["username"]
skin, err := cfg.SkinsRepo.FindByUsername(username)
if err != nil {
cfg.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested username")
return
}
cfg.deleteSkin(skin, resp)
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
func (cfg *Config) AuthenticationMiddleware(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("authentication.challenge", 1)
err := cfg.Auth.Check(req)
if err != nil {
if _, ok := err.(*auth.Unauthorized); ok {
cfg.Logger.IncCounter("authentication.failed", 1)
apiForbidden(resp, err.Error())
} else {
cfg.Logger.Error("Unknown error on validating api request: :err", wd.ErrParam(err))
apiServerError(resp)
}
return
}
cfg.Logger.IncCounter("authentication.success", 1)
handler.ServeHTTP(resp, req)
})
}
func (cfg *Config) deleteSkin(skin *model.Skin, resp http.ResponseWriter) {
err := cfg.SkinsRepo.RemoveByUserId(skin.UserId)
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
cfg.Logger.Error("Cannot delete skin by error: :err", wd.ErrParam(err))
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err))
apiServerError(resp)
return
}
if skin == nil {
apiNotFound(resp, "Cannot find record for the requested identifier")
return
}
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
}
cfg.Logger.IncCounter("api.skins.delete.success", 1)
resp.WriteHeader(http.StatusNoContent)
}
func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) {
record, err := ctx.SkinsRepo.FindSkinByUserId(identityId)
if err != nil {
return nil, err
}
if record != nil {
// The username may have changed in the external database,
// so we need to remove the old association
if record.Username != username {
_ = ctx.SkinsRepo.RemoveSkinByUserId(identityId)
record.Username = username
}
return record, nil
}
// If the requested id was not found, then username was reassigned to another user
// who has not uploaded his data to Chrly yet
record, err = ctx.SkinsRepo.FindSkinByUsername(username)
if err != nil {
return nil, err
}
// If the target username does exist, clear it as it will be reassigned to the new user
if record != nil {
_ = ctx.SkinsRepo.RemoveSkinByUsername(username)
record.UserId = identityId
return record, nil
}
return nil, nil
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
@@ -200,60 +223,3 @@ func validatePostSkinRequest(request *http.Request) map[string][]string {
return nil
}
func findIdentity(repo interfaces.SkinsRepository, identityId int, username string) (*model.Skin, error) {
var record *model.Skin
record, err := repo.FindByUserId(identityId)
if err != nil {
if _, isSkinNotFound := err.(*db.SkinNotFoundError); !isSkinNotFound {
return nil, err
}
record, err = repo.FindByUsername(username)
if err == nil {
_ = repo.RemoveByUsername(username)
record.UserId = identityId
} else {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
} else if record.Username != username {
_ = repo.RemoveByUserId(identityId)
record.Username = username
}
return record, nil
}
func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) {
resp.WriteHeader(http.StatusBadRequest)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"errors": errorsPerField,
})
_, _ = resp.Write(result)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"error": reason,
})
_, _ = resp.Write(result)
}
func apiNotFound(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusNotFound)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal([]interface{}{
reason,
})
_, _ = resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

View File

@@ -3,6 +3,8 @@ package http
import (
"bytes"
"encoding/base64"
"errors"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
@@ -10,217 +12,165 @@ import (
"net/url"
"testing"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/db"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/model"
)
func TestConfig_PostSkin(t *testing.T) {
t.Run("Upload new identity with textures info", func(t *testing.T) {
assert := testify.New(t)
/***************
* Setup mocks *
***************/
ctrl := gomock.NewController(t)
defer ctrl.Finish()
type apiTestSuite struct {
suite.Suite
config, mocks := setupMocks(ctrl)
App *Api
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Url = "http://example.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
SkinsRepository *skinsRepositoryMock
Emitter *emitterMock
}
form := url.Values{
/********************
* Setup test suite *
********************/
func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.Emitter = &emitterMock{}
suite.App = &Api{
SkinsRepo: suite.SkinsRepository,
Emitter: suite.Emitter,
}
}
func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *apiTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestApi(t *testing.T) {
suite.Run(t, new(apiTestSuite))
}
/*************************
* Post skin tests cases *
*************************/
type postSkinTestCase struct {
Name string
Form io.Reader
BeforeTest func(suite *apiTestSuite)
AfterTest func(suite *apiTestSuite, response *http.Response)
}
var postSkinTestsCases = []*postSkinTestCase{
{
Name: "Upload new identity with textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("http://example.com/skin.png", model.Url)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{Who: "unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{Who: "mock_user"})
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Upload new identity with skin file", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", body)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(response))
})
t.Run("Keep the same identityId, uuid and username, but change textures information", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Url = "http://textures-server.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
form := url.Values{
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing only textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.True(model.Is1_8)
suite.True(model.IsSlim)
suite.Equal("http://textures-server.com/skin.png", model.Url)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Keep the same uuid and username, but change identityId", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.UserId = 2
resultModel.SkinId = 5
resultModel.Url = "http://example.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its identityId",
Form: bytes.NewBufferString(url.Values{
"identityId": {"2"},
"username": {"mock_user"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 2).Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUsername", "mock_username").Times(1).Return(nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(2, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{Who: "unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Keep the same identityId and uuid, but change username", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("changed_username", false)
resultModel.SkinId = 5
resultModel.Url = "http://example.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its username",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
@@ -228,59 +178,106 @@ func TestConfig_PostSkin(t *testing.T) {
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Times(1).Return(nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("changed_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "unable to save record to the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "error on requesting a skin from the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
}
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
func (suite *apiTestSuite) TestPostSkin() {
for _, testCase := range postSkinTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
config.CreateHandler().ServeHTTP(w, req)
req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
suite.App.Handler().ServeHTTP(w, req)
t.Run("Get errors about required fields", func(t *testing.T) {
assert := testify.New(t)
testCase.AfterTest(suite, w.Result())
})
}
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
form := url.Values{
suite.RunSubTest("Get errors about required fields", func() {
req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{
"mojangTextures": {"someBase64EncodedString"},
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
suite.Equal(400, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
@@ -309,183 +306,127 @@ func TestConfig_PostSkin(t *testing.T) {
"The mojangSignature field is required"
]
}
}`, string(response))
}`, string(body))
})
t.Run("Perform request without authorization", func(t *testing.T) {
assert := testify.New(t)
suite.RunSubTest("Upload textures with skin as file", func() {
inputBody := &bytes.Buffer{}
writer := multipart.NewWriter(inputBody)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
config, mocks := setupMocks(ctrl)
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
req := httptest.NewRequest("POST", "http://chrly/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://chrly/skins", inputBody)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{Reason: "Cannot parse passed JWT token"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "Cannot parse passed JWT token"
}`, string(response))
suite.Equal(400, resp.StatusCode)
responseBody, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(responseBody))
})
}
func TestConfig_DeleteSkinByUserId(t *testing.T) {
t.Run("Delete skin by its identity id", func(t *testing.T) {
assert := testify.New(t)
/**************************************
* Delete skin by user id tests cases *
**************************************/
ctrl := gomock.NewController(t)
defer ctrl.Finish()
func (suite *apiTestSuite) TestDeleteByUserId() {
suite.RunSubTest("Delete skin by its identity id", func() {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
t.Run("Try to remove not exists identity id", func(t *testing.T) {
assert := testify.New(t)
suite.RunSubTest("Try to remove not exists identity id", func() {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:2", nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{Who: "unknown"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested user id"
]`, string(response))
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
func TestConfig_DeleteSkinByUsername(t *testing.T) {
t.Run("Delete skin by its identity username", func(t *testing.T) {
assert := testify.New(t)
/***************************************
* Delete skin by username tests cases *
***************************************/
ctrl := gomock.NewController(t)
defer ctrl.Finish()
func (suite *apiTestSuite) TestDeleteByUsername() {
suite.RunSubTest("Delete skin by its identity username", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_user", nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
t.Run("Try to remove not exists identity username", func(t *testing.T) {
assert := testify.New(t)
suite.RunSubTest("Try to remove not exists identity username", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_user_2", nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user_2").Return(nil, &db.SkinNotFoundError{Who: "mock_user_2"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested username"
]`, string(response))
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
func TestConfig_Authenticate(t *testing.T) {
t.Run("Test behavior when signing key is not set", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://localhost", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{Reason: "signing key not available"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
res := config.AuthenticationMiddleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {}))
res.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "signing key not available"
}`, string(response))
})
}
/*************
* Utilities *
*************/
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")

View File

@@ -1,51 +0,0 @@
package http
import (
"io"
"net/http"
"github.com/gorilla/mux"
)
func (cfg *Config) Cape(response http.ResponseWriter, request *http.Request) {
if mux.Vars(request)["converted"] == "" {
cfg.Logger.IncCounter("capes.request", 1)
}
username := parseUsername(mux.Vars(request)["username"])
rec, err := cfg.CapesRepo.FindByUsername(username)
if err == nil {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, rec.File)
return
}
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
if mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, cape.Url, 301)
}
func (cfg *Config) CapeGET(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("capes.get_request", 1)
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
cfg.Cape(response, request)
}

View File

@@ -1,163 +0,0 @@
package http
import (
"bytes"
"image"
"image/png"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/model"
)
type capesTestCase struct {
Name string
RequestUrl string
ExpectedLogKey string
ExistsInLocalStorage bool
ExistsInMojang bool
HasCapeInMojangResp bool
AssertResponse func(assert *testify.Assertions, resp *http.Response)
}
var capesTestCases = []*capesTestCase{
{
Name: "Obtain cape for known username",
ExistsInLocalStorage: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
assert.Equal(createCape(), responseData)
assert.Equal("image/png", resp.Header.Get("Content-Type"))
},
},
{
Name: "Obtain cape for unknown username that exists in Mojang and has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasCapeInMojangResp: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(301, resp.StatusCode)
assert.Equal("http://mojang/cape.png", resp.Header.Get("Location"))
},
},
{
Name: "Obtain cape for unknown username that exists in Mojang, but don't has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasCapeInMojangResp: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
{
Name: "Obtain cape for unknown username that doesn't exists in Mojang",
ExistsInLocalStorage: false,
ExistsInMojang: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
}
func TestConfig_Cape(t *testing.T) {
performTest := func(t *testing.T, testCase *capesTestCase) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter(testCase.ExpectedLogKey, int64(1))
if testCase.ExistsInLocalStorage {
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(&model.Cape{
File: bytes.NewReader(createCape()),
}, nil)
} else {
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{Who: "mock_username"})
}
if testCase.ExistsInMojang {
textures := createTexturesResponse(false, testCase.HasCapeInMojangResp)
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(textures)
} else {
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(nil)
}
req := httptest.NewRequest("GET", testCase.RequestUrl, nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
testCase.AssertResponse(assert, resp)
}
t.Run("Normal API", func(t *testing.T) {
for _, testCase := range capesTestCases {
testCase.RequestUrl = "http://chrly/cloaks/mock_username"
testCase.ExpectedLogKey = "capes.request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
})
t.Run("GET fallback API", func(t *testing.T) {
for _, testCase := range capesTestCases {
testCase.RequestUrl = "http://chrly/cloaks?name=mock_username"
testCase.ExpectedLogKey = "capes.get_request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
t.Run("Should trim trailing slash", func(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://chrly/cloaks/?name=notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://chrly/cloaks?name=notch", resp.Header.Get("Location"))
})
t.Run("Return error when name is not provided", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1))
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(400, resp.StatusCode)
})
})
}
// Cape md5: 424ff79dce9940af89c28ad80de8aaad
func createCape() []byte {
img := image.NewAlpha(image.Rect(0, 0, 64, 32))
writer := &bytes.Buffer{}
_ = png.Encode(writer, img)
pngBytes, _ := ioutil.ReadAll(writer)
return pngBytes
}

View File

@@ -1,88 +1,141 @@
package http
import (
"fmt"
"net"
"context"
"encoding/json"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/dispatcher"
v "github.com/elyby/chrly/version"
)
type Config struct {
ListenSpec string
SkinsRepo interfaces.SkinsRepository
CapesRepo interfaces.CapesRepository
MojangTexturesQueue interfaces.MojangTexturesQueue
Logger wd.Watchdog
Auth interfaces.AuthChecker
type Emitter interface {
dispatcher.Emitter
}
func (cfg *Config) Run() error {
cfg.Logger.Info(fmt.Sprintf("Starting, HTTP on: %s\n", cfg.ListenSpec))
func StartServer(server *http.Server, logger slf.Logger) {
logger.Debug("Chrly :v (:c)", wd.StringParam("v", v.Version()), wd.StringParam("c", v.Commit()))
listener, err := net.Listen("tcp", cfg.ListenSpec)
if err != nil {
return err
}
done := make(chan bool, 1)
go func() {
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
}
server := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: cfg.CreateHandler(),
}
close(done)
}()
go server.Serve(listener)
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String()))
server.Shutdown(context.Background())
logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String()))
close(done)
}()
s := waitForSignal()
cfg.Logger.Info(fmt.Sprintf("Got signal: %v, exiting.", s))
return nil
<-done
}
func (cfg *Config) CreateHandler() http.Handler {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", cfg.Skin).Methods("GET")
router.HandleFunc("/cloaks/{username}", cfg.Cape).Methods("GET").Name("cloaks")
router.HandleFunc("/textures/{username}", cfg.Textures).Methods("GET")
router.HandleFunc("/textures/signed/{username}", cfg.SignedTextures).Methods("GET")
// Legacy
router.HandleFunc("/skins", cfg.SkinGET).Methods("GET")
router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET")
// API
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(cfg.AuthenticationMiddleware)
apiRouter.Handle("/skins", http.HandlerFunc(cfg.PostSkin)).Methods("POST")
apiRouter.Handle("/skins/id:{id:[0-9]+}", http.HandlerFunc(cfg.DeleteSkinByUserId)).Methods("DELETE")
apiRouter.Handle("/skins/{username}", http.HandlerFunc(cfg.DeleteSkinByUsername)).Methods("DELETE")
// 404
router.NotFoundHandler = http.HandlerFunc(cfg.NotFound)
return router
}
func parseUsername(username string) string {
const suffix = ".png"
if strings.HasSuffix(username, suffix) {
username = strings.TrimSuffix(username, suffix)
}
return username
}
func waitForSignal() os.Signal {
ch := make(chan os.Signal)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
return <-ch
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func CreateRequestEventsMiddleware(emitter Emitter, prefix string) mux.MiddlewareFunc {
beforeTopic := strings.Join([]string{prefix, "before_request"}, ":")
afterTopic := strings.Join([]string{prefix, "after_request"}, ":")
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
emitter.Emit(beforeTopic, req)
lrw := &loggingResponseWriter{
ResponseWriter: resp,
statusCode: http.StatusOK,
}
handler.ServeHTTP(lrw, req)
emitter.Emit(afterTopic, req, lrw.statusCode)
})
}
}
type Authenticator interface {
Authenticate(req *http.Request) error
}
func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
err := checker.Authenticate(req)
if err != nil {
apiForbidden(resp, err.Error())
return
}
handler.ServeHTTP(resp, req)
})
}
}
func NotFoundHandler(response http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",
"message": "Not Found",
})
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusNotFound)
_, _ = response.Write(data)
}
func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) {
resp.WriteHeader(http.StatusBadRequest)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"errors": errorsPerField,
})
_, _ = resp.Write(result)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"error": reason,
})
_, _ = resp.Write(result)
}
func apiNotFound(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusNotFound)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal([]interface{}{
reason,
})
_, _ = resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

View File

@@ -1,89 +1,112 @@
package http
import (
"errors"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/tests"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/interfaces/mock_interfaces"
"github.com/elyby/chrly/interfaces/mock_wd"
"github.com/stretchr/testify/mock"
)
func TestParseUsername(t *testing.T) {
type emitterMock struct {
mock.Mock
}
func (e *emitterMock) Emit(name string, args ...interface{}) {
e.Called(append([]interface{}{name}, args...)...)
}
func TestCreateRequestEventsMiddleware(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
emitter := &emitterMock{}
emitter.On("Emit", "test_prefix:before_request", req)
emitter.On("Emit", "test_prefix:after_request", req, 400)
isHandlerCalled := false
middlewareFunc := CreateRequestEventsMiddleware(emitter, "test_prefix")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
resp.WriteHeader(400)
isHandlerCalled = true
})).ServeHTTP(resp, req)
if !isHandlerCalled {
t.Fatal("Handler isn't called from the middleware")
}
emitter.AssertExpectations(t)
}
type authCheckerMock struct {
mock.Mock
}
func (m *authCheckerMock) Authenticate(req *http.Request) error {
args := m.Called(req)
return args.Error(0)
}
func TestCreateAuthenticationMiddleware(t *testing.T) {
t.Run("pass", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(nil)
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.True(t, isHandlerCalled, "Handler isn't called from the middleware")
auth.AssertExpectations(t)
})
t.Run("fail", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(errors.New("error reason"))
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isHandlerCalled, "Handler shouldn't be called")
testify.Equal(t, 403, resp.Code)
body, _ := ioutil.ReadAll(resp.Body)
testify.JSONEq(t, `{
"error": "error reason"
}`, string(body))
auth.AssertExpectations(t)
})
}
func TestNotFoundHandler(t *testing.T) {
assert := testify.New(t)
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
}
type mocks struct {
Skins *mock_interfaces.MockSkinsRepository
Capes *mock_interfaces.MockCapesRepository
Queue *tests.MojangTexturesQueueMock
Auth *mock_interfaces.MockAuthChecker
Log *mock_wd.MockWatchdog
}
func setupMocks(ctrl *gomock.Controller) (
*Config,
*mocks,
) {
skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl)
capesRepo := mock_interfaces.NewMockCapesRepository(ctrl)
authChecker := mock_interfaces.NewMockAuthChecker(ctrl)
wd := mock_wd.NewMockWatchdog(ctrl)
texturesQueue := &tests.MojangTexturesQueueMock{}
return &Config{
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Auth: authChecker,
MojangTexturesQueue: texturesQueue,
Logger: wd,
}, &mocks{
Skins: skinsRepo,
Capes: capesRepo,
Auth: authChecker,
Queue: texturesQueue,
Log: wd,
}
}
func createTexturesResponse(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(),
ProfileID: "00000000000000000000000000000000",
ProfileName: "mock_user",
Textures: &mojang.TexturesResponse{},
}
if includeSkin {
textures.Textures.Skin = &mojang.SkinTexturesResponse{
Url: "http://mojang/skin.png",
}
}
if includeCape {
textures.Textures.Cape = &mojang.CapeTexturesResponse{
Url: "http://mojang/cape.png",
}
}
response := &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock_user",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
},
},
}
return response
req := httptest.NewRequest("GET", "http://example.com", nil)
w := httptest.NewRecorder()
NotFoundHandler(w, req)
resp := w.Result()
assert.Equal(404, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"status": "404",
"message": "Not Found"
}`, string(response))
}

View File

@@ -1,4 +1,4 @@
package auth
package http
import (
"errors"
@@ -21,6 +21,7 @@ var (
)
type JwtAuth struct {
Emitter
Key []byte
}
@@ -41,42 +42,37 @@ func (t *JwtAuth) NewToken(scopes ...Scope) ([]byte, error) {
return token, nil
}
func (t *JwtAuth) Check(req *http.Request) error {
func (t *JwtAuth) Authenticate(req *http.Request) error {
if len(t.Key) == 0 {
return &Unauthorized{"Signing key not set"}
return t.emitErr(errors.New("Signing key not set"))
}
bearerToken := req.Header.Get("Authorization")
if bearerToken == "" {
return &Unauthorized{"Authentication header not presented"}
return t.emitErr(errors.New("Authentication header not presented"))
}
if !strings.EqualFold(bearerToken[0:7], "BEARER ") {
return &Unauthorized{"Cannot recognize JWT token in passed value"}
return t.emitErr(errors.New("Cannot recognize JWT token in passed value"))
}
tokenStr := bearerToken[7:]
token, err := jws.ParseJWT([]byte(tokenStr))
if err != nil {
return &Unauthorized{"Cannot parse passed JWT token"}
return t.emitErr(errors.New("Cannot parse passed JWT token"))
}
err = token.Validate(t.Key, hashAlg)
if err != nil {
return &Unauthorized{"JWT token have invalid signature. It may be corrupted or expired."}
return t.emitErr(errors.New("JWT token have invalid signature. It may be corrupted or expired"))
}
t.Emit("authentication:success")
return nil
}
type Unauthorized struct {
Reason string
}
func (e *Unauthorized) Error() string {
if e.Reason != "" {
return e.Reason
}
return "Unauthorized"
func (t *JwtAuth) emitErr(err error) error {
t.Emit("authentication:error", err)
return err
}

127
http/jwt_test.go Normal file
View File

@@ -0,0 +1,127 @@
package http
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8"
func TestJwtAuth_NewToken(t *testing.T) {
t.Run("success", func(t *testing.T) {
jwt := &JwtAuth{Key: []byte("secret")}
token, err := jwt.NewToken(SkinScope)
assert.Nil(t, err)
assert.NotNil(t, token)
})
t.Run("key not provided", func(t *testing.T) {
jwt := &JwtAuth{}
token, err := jwt.NewToken(SkinScope)
assert.Error(t, err, "signing key not available")
assert.Nil(t, token)
})
}
func TestJwtAuth_Authenticate(t *testing.T) {
t.Run("success", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:success")
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Nil(t, err)
emitter.AssertExpectations(t)
})
t.Run("request without auth header", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Authentication header not presented")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Authentication header not presented")
emitter.AssertExpectations(t)
})
t.Run("no bearer token prefix", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Cannot recognize JWT token in passed value")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "this is not jwt")
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Cannot recognize JWT token in passed value")
emitter.AssertExpectations(t)
})
t.Run("bearer token but not jwt", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Cannot parse passed JWT token")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt")
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Cannot parse passed JWT token")
emitter.AssertExpectations(t)
})
t.Run("when secret is not set", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Signing key not set")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Signing key not set")
emitter.AssertExpectations(t)
})
t.Run("invalid signature", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Key: []byte("this is another secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired")
emitter.AssertExpectations(t)
})
}

View File

@@ -1,18 +0,0 @@
package http
import (
"encoding/json"
"net/http"
)
func (cfg *Config) NotFound(response http.ResponseWriter, request *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",
"message": "Not Found",
"link": "http://docs.ely.by/skin-system.html",
})
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusNotFound)
response.Write(data)
}

View File

@@ -1,28 +0,0 @@
package http
import (
"io/ioutil"
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
)
func TestConfig_NotFound(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(404, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"status": "404",
"message": "Not Found",
"link": "http://docs.ely.by/skin-system.html"
}`, string(response))
}

View File

@@ -1,49 +0,0 @@
package http
import (
"encoding/json"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
)
func (cfg *Config) SignedTextures(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("signed_textures.request", 1)
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
rec, err := cfg.SkinsRepo.FindByUsername(username)
if err == nil && rec.SkinId != 0 && rec.MojangTextures != "" {
responseData = &mojang.SignedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: rec.MojangSignature,
Value: rec.MojangTextures,
},
},
}
} else if request.URL.Query().Get("proxy") != "" {
responseData = <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
}
if responseData == nil {
response.WriteHeader(http.StatusNoContent)
return
}
responseData.Props = append(responseData.Props, &mojang.Property{
Name: "ely",
Value: "but why are you asking?",
})
responseJson, _ := json.Marshal(responseData)
response.Header().Set("Content-Type", "application/json")
response.Write(responseJson)
}

View File

@@ -1,141 +0,0 @@
package http
import (
"io/ioutil"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
)
func TestConfig_SignedTextures(t *testing.T) {
t.Run("Obtain signed textures for exists user", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_user",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "ely",
"value": "but why are you asking?"
}
]
}`, string(response))
})
t.Run("Obtain signed textures for not exists user", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{})
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
})
t.Run("Obtain signed textures for exists user, but without signed textures", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
skinModel := createSkinModel("mock_user", false)
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(skinModel, nil)
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
})
t.Run("Obtain signed textures for exists user, but without signed textures", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
skinModel := createSkinModel("mock_user", false)
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(skinModel, nil)
mocks.Queue.On("GetTexturesForUsername", "mock_user").Once().Return(createTexturesResponse(true, false))
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user?proxy=true", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "00000000000000000000000000000000",
"name": "mock_user",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXIiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9tb2phbmcvc2tpbi5wbmcifX19"
},
{
"name": "ely",
"value": "but why are you asking?"
}
]
}`, string(response))
})
}

View File

@@ -1,49 +0,0 @@
package http
import (
"net/http"
"github.com/gorilla/mux"
)
func (cfg *Config) Skin(response http.ResponseWriter, request *http.Request) {
if mux.Vars(request)["converted"] == "" {
cfg.Logger.IncCounter("skins.request", 1)
}
username := parseUsername(mux.Vars(request)["username"])
rec, err := cfg.SkinsRepo.FindByUsername(username)
if err == nil && rec.SkinId != 0 {
http.Redirect(response, request, rec.Url, 301)
return
}
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
if mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
skin := texturesProp.Textures.Skin
if skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, skin.Url, 301)
}
func (cfg *Config) SkinGET(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("skins.get_request", 1)
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
cfg.Skin(response, request)
}

View File

@@ -1,158 +0,0 @@
package http
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/model"
)
type skinsTestCase struct {
Name string
RequestUrl string
ExpectedLogKey string
ExistsInLocalStorage bool
ExistsInMojang bool
HasSkinInMojangResp bool
AssertResponse func(assert *testify.Assertions, resp *http.Response)
}
var skinsTestCases = []*skinsTestCase{
{
Name: "Obtain skin for known username",
ExistsInLocalStorage: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(301, resp.StatusCode)
assert.Equal("http://chrly/skin.png", resp.Header.Get("Location"))
},
},
{
Name: "Obtain skin for unknown username that exists in Mojang and has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasSkinInMojangResp: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(301, resp.StatusCode)
assert.Equal("http://mojang/skin.png", resp.Header.Get("Location"))
},
},
{
Name: "Obtain skin for unknown username that exists in Mojang, but don't has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasSkinInMojangResp: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
{
Name: "Obtain skin for unknown username that doesn't exists in Mojang",
ExistsInLocalStorage: false,
ExistsInMojang: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
}
func TestConfig_Skin(t *testing.T) {
performTest := func(t *testing.T, testCase *skinsTestCase) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter(testCase.ExpectedLogKey, int64(1))
if testCase.ExistsInLocalStorage {
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(createSkinModel("mock_username", false), nil)
} else {
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{Who: "mock_username"})
}
if testCase.ExistsInMojang {
textures := createTexturesResponse(testCase.HasSkinInMojangResp, true)
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(textures)
} else {
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(nil)
}
req := httptest.NewRequest("GET", testCase.RequestUrl, nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
testCase.AssertResponse(assert, resp)
}
t.Run("Normal API", func(t *testing.T) {
for _, testCase := range skinsTestCases {
testCase.RequestUrl = "http://chrly/skins/mock_username"
testCase.ExpectedLogKey = "skins.request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
})
t.Run("GET fallback API", func(t *testing.T) {
for _, testCase := range skinsTestCases {
testCase.RequestUrl = "http://chrly/skins?name=mock_username"
testCase.ExpectedLogKey = "skins.get_request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
t.Run("Should trim trailing slash", func(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://chrly/skins/?name=notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://chrly/skins?name=notch", resp.Header.Get("Location"))
})
t.Run("Return error when name is not provided", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1))
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(400, resp.StatusCode)
})
})
}
func createSkinModel(username string, isSlim bool) *model.Skin {
return &model.Skin{
UserId: 1,
Username: username,
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", // Use non nil UUID to pass validation in api tests
SkinId: 1,
Url: "http://chrly/skin.png",
MojangTextures: "mocked textures base64",
MojangSignature: "mocked signature",
IsSlim: isSlim,
}
}

226
http/skinsystem.go Normal file
View File

@@ -0,0 +1,226 @@
package http
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
type SkinsRepository interface {
FindSkinByUsername(username string) (*model.Skin, error)
FindSkinByUserId(id int) (*model.Skin, error)
SaveSkin(skin *model.Skin) error
RemoveSkinByUserId(id int) error
RemoveSkinByUsername(username string) error
}
type CapesRepository interface {
FindCapeByUsername(username string) (*model.Cape, error)
}
type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
}
type Skinsystem struct {
Emitter
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
TexturesExtraParamName string
TexturesExtraParamValue string
}
func (ctx *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet)
// Legacy
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
return router
}
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != nil && rec.SkinId != 0 {
http.Redirect(response, request, rec.Url, 301)
return
}
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
skin := texturesProp.Textures.Skin
if skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, skin.Url, 301)
}
func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.skinHandler(response, request)
}
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.CapesRepo.FindCapeByUsername(username)
if err == nil && rec != nil {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, rec.File)
return
}
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, cape.Url, 301)
}
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.capeHandler(response, request)
}
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var textures *mojang.TexturesResponse
skin, skinErr := ctx.SkinsRepo.FindSkinByUsername(username)
cape, capeErr := ctx.CapesRepo.FindCapeByUsername(username)
if (skinErr == nil && skin != nil && skin.SkinId != 0) || (capeErr == nil && cape != nil) {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin != nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
skinTextures.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
textures.Skin = skinTextures
}
if capeErr == nil && cape != nil {
textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
} else {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesProp := mojangTextures.DecodeTextures()
if texturesProp == nil {
ctx.Emit("skinsystem:error", errors.New("unable to find textures property"))
apiServerError(response)
return
}
textures = texturesProp.Textures
if textures.Skin == nil && textures.Cape == nil {
response.WriteHeader(http.StatusNoContent)
return
}
}
responseData, _ := json.Marshal(textures)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != nil && rec.SkinId != 0 && rec.MojangTextures != "" {
responseData = &mojang.SignedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: rec.MojangSignature,
Value: rec.MojangTextures,
},
},
}
} else if request.URL.Query().Get("proxy") != "" {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err == nil && mojangTextures != nil {
responseData = mojangTextures
}
}
if responseData == nil {
response.WriteHeader(http.StatusNoContent)
return
}
responseData.Props = append(responseData.Props, &mojang.Property{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
})
responseJson, _ := json.Marshal(responseData)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}

702
http/skinsystem_test.go Normal file
View File

@@ -0,0 +1,702 @@
package http
import (
"bytes"
"image"
"image/png"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
/***************
* Setup mocks *
***************/
type skinsRepositoryMock struct {
mock.Mock
}
func (m *skinsRepositoryMock) FindSkinByUsername(username string) (*model.Skin, error) {
args := m.Called(username)
var result *model.Skin
if casted, ok := args.Get(0).(*model.Skin); ok {
result = casted
}
return result, args.Error(1)
}
func (m *skinsRepositoryMock) FindSkinByUserId(id int) (*model.Skin, error) {
args := m.Called(id)
var result *model.Skin
if casted, ok := args.Get(0).(*model.Skin); ok {
result = casted
}
return result, args.Error(1)
}
func (m *skinsRepositoryMock) SaveSkin(skin *model.Skin) error {
args := m.Called(skin)
return args.Error(0)
}
func (m *skinsRepositoryMock) RemoveSkinByUserId(id int) error {
args := m.Called(id)
return args.Error(0)
}
func (m *skinsRepositoryMock) RemoveSkinByUsername(username string) error {
args := m.Called(username)
return args.Error(0)
}
type capesRepositoryMock struct {
mock.Mock
}
func (m *capesRepositoryMock) FindCapeByUsername(username string) (*model.Cape, error) {
args := m.Called(username)
var result *model.Cape
if casted, ok := args.Get(0).(*model.Cape); ok {
result = casted
}
return result, args.Error(1)
}
type mojangTexturesProviderMock struct {
mock.Mock
}
func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(username)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type skinsystemTestSuite struct {
suite.Suite
App *Skinsystem
SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock
Emitter *emitterMock
}
/********************
* Setup test suite *
********************/
func (suite *skinsystemTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
Emitter: suite.Emitter,
TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue",
}
}
func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *skinsystemTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestSkinsystem(t *testing.T) {
suite.Run(t, new(skinsystemTestSuite))
}
type skinsystemTestCase struct {
Name string
BeforeTest func(suite *skinsystemTestSuite)
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
/************************
* Get skin tests cases *
************************/
var skinsTestsCases = []*skinsystemTestCase{
{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
suite.Equal("http://chrly/skin.png", response.Header.Get("Location"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
suite.Equal("http://mojang/skin.png", response.Header.Get("Location"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
}
func (suite *skinsystemTestSuite) TestSkin() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(301, resp.StatusCode)
suite.Equal("http://chrly/skin.png", resp.Header.Get("Location"))
})
}
func (suite *skinsystemTestSuite) TestSkinGET() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(400, resp.StatusCode)
})
}
/************************
* Get cape tests cases *
************************/
var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
responseData, _ := ioutil.ReadAll(response.Body)
suite.Equal(createCape(), responseData)
suite.Equal("image/png", response.Header.Get("Content-Type"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
suite.Equal("http://mojang/cape.png", response.Header.Get("Location"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
}
func (suite *skinsystemTestSuite) TestCape() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
suite.Equal(createCape(), responseData)
suite.Equal("image/png", resp.Header.Get("Content-Type"))
})
}
func (suite *skinsystemTestSuite) TestCapeGET() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(400, resp.StatusCode)
})
}
/****************************
* Get textures tests cases *
****************************/
var texturesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists and has skin, no cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
}
}`, string(body))
},
},
{
Name: "Username exists and has slim skin, no cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png",
"metadata": {
"model": "slim"
}
}
}`, string(body))
},
},
{
Name: "Username exists and has cape, no skin",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
{
Name: "Username exists and has both skin and cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
},
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://mojang/skin.png"
},
"CAPE": {
"url": "http://mojang/cape.png"
}
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
},
},
{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
}
func (suite *skinsystemTestSuite) TestTextures() {
for _, testCase := range texturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}
/***********************************
* Get signed textures tests cases *
***********************************/
type signedTexturesTestCase struct {
Name string
AllowProxy bool
BeforeTest func(suite *skinsystemTestSuite)
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var signedTexturesTestsCases = []*signedTexturesTestCase{
{
Name: "Username exists",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username exists, but has no signed textures",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
skinModel := createSkinModel("mock_username", true)
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username not exists, but Mojang profile is available and proxying is enabled",
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "00000000000000000000000000000000",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, Mojang profile is unavailable too and proxying is enabled",
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
}
func (suite *skinsystemTestSuite) TestSignedTextures() {
for _, testCase := range signedTexturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
var target string
if testCase.AllowProxy {
target = "http://chrly/textures/signed/mock_username?proxy=true"
} else {
target = "http://chrly/textures/signed/mock_username"
}
req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}
/****************
* Custom tests *
****************/
func TestParseUsername(t *testing.T) {
assert := testify.New(t)
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
}
/*************
* Utilities *
*************/
func createSkinModel(username string, isSlim bool) *model.Skin {
return &model.Skin{
UserId: 1,
Username: username,
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", // Use non nil UUID to pass validation in api tests
SkinId: 1,
Url: "http://chrly/skin.png",
MojangTextures: "mocked textures base64",
MojangSignature: "mocked signature",
IsSlim: isSlim,
}
}
func createCape() []byte {
img := image.NewAlpha(image.Rect(0, 0, 64, 32))
writer := &bytes.Buffer{}
_ = png.Encode(writer, img)
pngBytes, _ := ioutil.ReadAll(writer)
return pngBytes
}
func createCapeModel() *model.Cape {
return &model.Cape{File: bytes.NewReader(createCape())}
}
func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(),
ProfileID: "00000000000000000000000000000000",
ProfileName: "mock_username",
Textures: &mojang.TexturesResponse{},
}
if includeSkin {
textures.Textures.Skin = &mojang.SkinTexturesResponse{
Url: "http://mojang/skin.png",
}
}
if includeCape {
textures.Textures.Cape = &mojang.CapeTexturesResponse{
Url: "http://mojang/cape.png",
}
}
response := &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock_username",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
},
},
}
return response
}

View File

@@ -1,61 +0,0 @@
package http
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
)
func (cfg *Config) Textures(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("textures.request", 1)
username := parseUsername(mux.Vars(request)["username"])
var textures *mojang.TexturesResponse
skin, skinErr := cfg.SkinsRepo.FindByUsername(username)
_, capeErr := cfg.CapesRepo.FindByUsername(username)
if (skinErr == nil && skin.SkinId != 0) || capeErr == nil {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
skinTextures.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
textures.Skin = skinTextures
}
if capeErr == nil {
textures.Cape = &mojang.CapeTexturesResponse{
Url: request.URL.Scheme + "://" + request.Host + "/cloaks/" + username,
}
}
} else {
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
if mojangTextures == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesProp := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusInternalServerError)
cfg.Logger.Error("Unable to find textures property")
return
}
textures = texturesProp.Textures
}
responseData, _ := json.Marshal(textures)
response.Header().Set("Content-Type", "application/json")
response.Write(responseData)
}

View File

@@ -1,194 +0,0 @@
package http
import (
"bytes"
"io/ioutil"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/model"
)
func TestConfig_Textures(t *testing.T) {
t.Run("Obtain textures for exists user with only default skin", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{Who: "mock_user"})
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
}
}`, string(response))
})
t.Run("Obtain textures for exists user with only slim skin", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{Who: "mock_user"})
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png",
"metadata": {
"model": "slim"
}
}
}`, string(response))
})
t.Run("Obtain textures for exists user with only cape", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{Who: "mock_user"})
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{File: bytes.NewReader(createCape())}, nil)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_user"
}
}`, string(response))
})
t.Run("Obtain textures for exists user with skin and cape", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{File: bytes.NewReader(createCape())}, nil)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
},
"CAPE": {
"url": "http://chrly/cloaks/mock_user"
}
}`, string(response))
})
t.Run("Obtain textures for not exists user that exists in Mojang", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{})
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{})
mocks.Queue.On("GetTexturesForUsername", "mock_username").Once().Return(createTexturesResponse(true, true))
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://mojang/skin.png"
},
"CAPE": {
"url": "http://mojang/cape.png"
}
}`, string(response))
})
t.Run("Obtain textures for not exists user that not exists in Mojang too", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{})
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{})
mocks.Queue.On("GetTexturesForUsername", "mock_username").Once().Return(nil)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
})
}

53
http/uuids_worker.go Normal file
View File

@@ -0,0 +1,53 @@
package http
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
)
type MojangUuidsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type UUIDsWorker struct {
MojangUuidsProvider
}
func (ctx *UUIDsWorker) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.Handle("/mojang-uuid/{username}", http.HandlerFunc(ctx.getUUIDHandler)).Methods("GET")
return router
}
func (ctx *UUIDsWorker) getUUIDHandler(response http.ResponseWriter, request *http.Request) {
username := mux.Vars(request)["username"]
profile, err := ctx.GetUuid(username)
if err != nil {
if _, ok := err.(*mojang.TooManyRequestsError); ok {
response.WriteHeader(http.StatusTooManyRequests)
return
}
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusInternalServerError)
result, _ := json.Marshal(map[string]interface{}{
"provider": err.Error(),
})
_, _ = response.Write(result)
return
}
if profile == nil {
response.WriteHeader(http.StatusNoContent)
return
}
response.Header().Set("Content-Type", "application/json")
responseData, _ := json.Marshal(profile)
_, _ = response.Write(responseData)
}

154
http/uuids_worker_test.go Normal file
View File

@@ -0,0 +1,154 @@
package http
import (
"errors"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
/***************
* Setup mocks *
***************/
type uuidsProviderMock struct {
mock.Mock
}
func (m *uuidsProviderMock) GetUuid(username string) (*mojang.ProfileInfo, error) {
args := m.Called(username)
var result *mojang.ProfileInfo
if casted, ok := args.Get(0).(*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type uuidsWorkerTestSuite struct {
suite.Suite
App *UUIDsWorker
UuidsProvider *uuidsProviderMock
}
/********************
* Setup test suite *
********************/
func (suite *uuidsWorkerTestSuite) SetupTest() {
suite.UuidsProvider = &uuidsProviderMock{}
suite.App = &UUIDsWorker{
MojangUuidsProvider: suite.UuidsProvider,
}
}
func (suite *uuidsWorkerTestSuite) TearDownTest() {
suite.UuidsProvider.AssertExpectations(suite.T())
}
func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestUUIDsWorker(t *testing.T) {
suite.Run(t, new(uuidsWorkerTestSuite))
}
type uuidsWorkerTestCase struct {
Name string
BeforeTest func(suite *uuidsWorkerTestSuite)
AfterTest func(suite *uuidsWorkerTestSuite, response *http.Response)
}
/************************
* Get UUID tests cases *
************************/
var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Success provider response",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.UuidsProvider.On("GetUuid", "mock_username").Return(&mojang.ProfileInfo{
Id: "0fcc38620f1845f3a54e1b523c1bd1c7",
Name: "mock_username",
}, nil)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0fcc38620f1845f3a54e1b523c1bd1c7",
"name": "mock_username"
}`, string(body))
},
},
{
Name: "Receive empty response from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Assert().Empty(body)
},
},
{
Name: "Receive error from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
err := errors.New("this is an error")
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"provider": "this is an error"
}`, string(body))
},
},
{
Name: "Receive Too Many Requests from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
err := &mojang.TooManyRequestsError{}
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(429, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
}
func (suite *uuidsWorkerTestSuite) TestGetUUID() {
for _, testCase := range getUuidTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/mojang-uuid/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}

View File

@@ -1,7 +0,0 @@
package interfaces
import "net/http"
type AuthChecker interface {
Check(req *http.Request) error
}

View File

@@ -1,45 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: interfaces/auth.go
package mock_interfaces
import (
gomock "github.com/golang/mock/gomock"
http "net/http"
reflect "reflect"
)
// MockAuthChecker is a mock of AuthChecker interface
type MockAuthChecker struct {
ctrl *gomock.Controller
recorder *MockAuthCheckerMockRecorder
}
// MockAuthCheckerMockRecorder is the mock recorder for MockAuthChecker
type MockAuthCheckerMockRecorder struct {
mock *MockAuthChecker
}
// NewMockAuthChecker creates a new mock instance
func NewMockAuthChecker(ctrl *gomock.Controller) *MockAuthChecker {
mock := &MockAuthChecker{ctrl: ctrl}
mock.recorder = &MockAuthCheckerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockAuthChecker) EXPECT() *MockAuthCheckerMockRecorder {
return _m.recorder
}
// Check mocks base method
func (_m *MockAuthChecker) Check(req *http.Request) error {
ret := _m.ctrl.Call(_m, "Check", req)
ret0, _ := ret[0].(error)
return ret0
}
// Check indicates an expected call of Check
func (_mr *MockAuthCheckerMockRecorder) Check(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Check", reflect.TypeOf((*MockAuthChecker)(nil).Check), arg0)
}

View File

@@ -1,131 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: interfaces/repositories.go
package mock_interfaces
import (
model "github.com/elyby/chrly/model"
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockSkinsRepository is a mock of SkinsRepository interface
type MockSkinsRepository struct {
ctrl *gomock.Controller
recorder *MockSkinsRepositoryMockRecorder
}
// MockSkinsRepositoryMockRecorder is the mock recorder for MockSkinsRepository
type MockSkinsRepositoryMockRecorder struct {
mock *MockSkinsRepository
}
// NewMockSkinsRepository creates a new mock instance
func NewMockSkinsRepository(ctrl *gomock.Controller) *MockSkinsRepository {
mock := &MockSkinsRepository{ctrl: ctrl}
mock.recorder = &MockSkinsRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockSkinsRepository) EXPECT() *MockSkinsRepositoryMockRecorder {
return _m.recorder
}
// FindByUsername mocks base method
func (_m *MockSkinsRepository) FindByUsername(username string) (*model.Skin, error) {
ret := _m.ctrl.Call(_m, "FindByUsername", username)
ret0, _ := ret[0].(*model.Skin)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByUsername indicates an expected call of FindByUsername
func (_mr *MockSkinsRepositoryMockRecorder) FindByUsername(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "FindByUsername", reflect.TypeOf((*MockSkinsRepository)(nil).FindByUsername), arg0)
}
// FindByUserId mocks base method
func (_m *MockSkinsRepository) FindByUserId(id int) (*model.Skin, error) {
ret := _m.ctrl.Call(_m, "FindByUserId", id)
ret0, _ := ret[0].(*model.Skin)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByUserId indicates an expected call of FindByUserId
func (_mr *MockSkinsRepositoryMockRecorder) FindByUserId(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "FindByUserId", reflect.TypeOf((*MockSkinsRepository)(nil).FindByUserId), arg0)
}
// Save mocks base method
func (_m *MockSkinsRepository) Save(skin *model.Skin) error {
ret := _m.ctrl.Call(_m, "Save", skin)
ret0, _ := ret[0].(error)
return ret0
}
// Save indicates an expected call of Save
func (_mr *MockSkinsRepositoryMockRecorder) Save(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Save", reflect.TypeOf((*MockSkinsRepository)(nil).Save), arg0)
}
// RemoveByUserId mocks base method
func (_m *MockSkinsRepository) RemoveByUserId(id int) error {
ret := _m.ctrl.Call(_m, "RemoveByUserId", id)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveByUserId indicates an expected call of RemoveByUserId
func (_mr *MockSkinsRepositoryMockRecorder) RemoveByUserId(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RemoveByUserId", reflect.TypeOf((*MockSkinsRepository)(nil).RemoveByUserId), arg0)
}
// RemoveByUsername mocks base method
func (_m *MockSkinsRepository) RemoveByUsername(username string) error {
ret := _m.ctrl.Call(_m, "RemoveByUsername", username)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveByUsername indicates an expected call of RemoveByUsername
func (_mr *MockSkinsRepositoryMockRecorder) RemoveByUsername(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RemoveByUsername", reflect.TypeOf((*MockSkinsRepository)(nil).RemoveByUsername), arg0)
}
// MockCapesRepository is a mock of CapesRepository interface
type MockCapesRepository struct {
ctrl *gomock.Controller
recorder *MockCapesRepositoryMockRecorder
}
// MockCapesRepositoryMockRecorder is the mock recorder for MockCapesRepository
type MockCapesRepositoryMockRecorder struct {
mock *MockCapesRepository
}
// NewMockCapesRepository creates a new mock instance
func NewMockCapesRepository(ctrl *gomock.Controller) *MockCapesRepository {
mock := &MockCapesRepository{ctrl: ctrl}
mock.recorder = &MockCapesRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockCapesRepository) EXPECT() *MockCapesRepositoryMockRecorder {
return _m.recorder
}
// FindByUsername mocks base method
func (_m *MockCapesRepository) FindByUsername(username string) (*model.Cape, error) {
ret := _m.ctrl.Call(_m, "FindByUsername", username)
ret0, _ := ret[0].(*model.Cape)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByUsername indicates an expected call of FindByUsername
func (_mr *MockCapesRepositoryMockRecorder) FindByUsername(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "FindByUsername", reflect.TypeOf((*MockCapesRepository)(nil).FindByUsername), arg0)
}

View File

@@ -1,218 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mono83/slf/wd (interfaces: Watchdog)
package mock_wd
import (
gomock "github.com/golang/mock/gomock"
slf "github.com/mono83/slf"
wd "github.com/mono83/slf/wd"
reflect "reflect"
time "time"
)
// MockWatchdog is a mock of Watchdog interface
type MockWatchdog struct {
ctrl *gomock.Controller
recorder *MockWatchdogMockRecorder
}
// MockWatchdogMockRecorder is the mock recorder for MockWatchdog
type MockWatchdogMockRecorder struct {
mock *MockWatchdog
}
// NewMockWatchdog creates a new mock instance
func NewMockWatchdog(ctrl *gomock.Controller) *MockWatchdog {
mock := &MockWatchdog{ctrl: ctrl}
mock.recorder = &MockWatchdogMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockWatchdog) EXPECT() *MockWatchdogMockRecorder {
return _m.recorder
}
// Alert mocks base method
func (_m *MockWatchdog) Alert(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Alert", _s...)
}
// Alert indicates an expected call of Alert
func (_mr *MockWatchdogMockRecorder) Alert(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Alert", reflect.TypeOf((*MockWatchdog)(nil).Alert), _s...)
}
// Debug mocks base method
func (_m *MockWatchdog) Debug(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Debug", _s...)
}
// Debug indicates an expected call of Debug
func (_mr *MockWatchdogMockRecorder) Debug(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Debug", reflect.TypeOf((*MockWatchdog)(nil).Debug), _s...)
}
// Emergency mocks base method
func (_m *MockWatchdog) Emergency(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Emergency", _s...)
}
// Emergency indicates an expected call of Emergency
func (_mr *MockWatchdogMockRecorder) Emergency(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Emergency", reflect.TypeOf((*MockWatchdog)(nil).Emergency), _s...)
}
// Error mocks base method
func (_m *MockWatchdog) Error(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Error", _s...)
}
// Error indicates an expected call of Error
func (_mr *MockWatchdogMockRecorder) Error(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Error", reflect.TypeOf((*MockWatchdog)(nil).Error), _s...)
}
// IncCounter mocks base method
func (_m *MockWatchdog) IncCounter(_param0 string, _param1 int64, _param2 ...slf.Param) {
_s := []interface{}{_param0, _param1}
for _, _x := range _param2 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "IncCounter", _s...)
}
// IncCounter indicates an expected call of IncCounter
func (_mr *MockWatchdogMockRecorder) IncCounter(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0, arg1}, arg2...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "IncCounter", reflect.TypeOf((*MockWatchdog)(nil).IncCounter), _s...)
}
// Info mocks base method
func (_m *MockWatchdog) Info(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Info", _s...)
}
// Info indicates an expected call of Info
func (_mr *MockWatchdogMockRecorder) Info(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Info", reflect.TypeOf((*MockWatchdog)(nil).Info), _s...)
}
// RecordTimer mocks base method
func (_m *MockWatchdog) RecordTimer(_param0 string, _param1 time.Duration, _param2 ...slf.Param) {
_s := []interface{}{_param0, _param1}
for _, _x := range _param2 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "RecordTimer", _s...)
}
// RecordTimer indicates an expected call of RecordTimer
func (_mr *MockWatchdogMockRecorder) RecordTimer(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0, arg1}, arg2...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RecordTimer", reflect.TypeOf((*MockWatchdog)(nil).RecordTimer), _s...)
}
// Timer mocks base method
func (_m *MockWatchdog) Timer(_param0 string, _param1 ...slf.Param) slf.Timer {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
ret := _m.ctrl.Call(_m, "Timer", _s...)
ret0, _ := ret[0].(slf.Timer)
return ret0
}
// Timer indicates an expected call of Timer
func (_mr *MockWatchdogMockRecorder) Timer(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Timer", reflect.TypeOf((*MockWatchdog)(nil).Timer), _s...)
}
// Trace mocks base method
func (_m *MockWatchdog) Trace(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Trace", _s...)
}
// Trace indicates an expected call of Trace
func (_mr *MockWatchdogMockRecorder) Trace(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Trace", reflect.TypeOf((*MockWatchdog)(nil).Trace), _s...)
}
// UpdateGauge mocks base method
func (_m *MockWatchdog) UpdateGauge(_param0 string, _param1 int64, _param2 ...slf.Param) {
_s := []interface{}{_param0, _param1}
for _, _x := range _param2 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "UpdateGauge", _s...)
}
// UpdateGauge indicates an expected call of UpdateGauge
func (_mr *MockWatchdogMockRecorder) UpdateGauge(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0, arg1}, arg2...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "UpdateGauge", reflect.TypeOf((*MockWatchdog)(nil).UpdateGauge), _s...)
}
// Warning mocks base method
func (_m *MockWatchdog) Warning(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Warning", _s...)
}
// Warning indicates an expected call of Warning
func (_mr *MockWatchdogMockRecorder) Warning(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Warning", reflect.TypeOf((*MockWatchdog)(nil).Warning), _s...)
}
// WithParams mocks base method
func (_m *MockWatchdog) WithParams(_param0 ...slf.Param) wd.Watchdog {
_s := []interface{}{}
for _, _x := range _param0 {
_s = append(_s, _x)
}
ret := _m.ctrl.Call(_m, "WithParams", _s...)
ret0, _ := ret[0].(wd.Watchdog)
return ret0
}
// WithParams indicates an expected call of WithParams
func (_mr *MockWatchdogMockRecorder) WithParams(arg0 ...interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "WithParams", reflect.TypeOf((*MockWatchdog)(nil).WithParams), arg0...)
}

View File

@@ -1,22 +0,0 @@
package interfaces
import (
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
type SkinsRepository interface {
FindByUsername(username string) (*model.Skin, error)
FindByUserId(id int) (*model.Skin, error)
Save(skin *model.Skin) error
RemoveByUserId(id int) error
RemoveByUsername(username string) error
}
type CapesRepository interface {
FindByUsername(username string) (*model.Cape, error)
}
type MojangTexturesQueue interface {
GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse
}

View File

@@ -0,0 +1,139 @@
package mojangtextures
import (
"strings"
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
)
type jobResult struct {
profile *mojang.ProfileInfo
error error
}
type jobItem struct {
username string
respondChan chan *jobResult
}
type jobsQueue struct {
lock sync.Mutex
items []*jobItem
}
func (s *jobsQueue) New() *jobsQueue {
s.items = []*jobItem{}
return s
}
func (s *jobsQueue) Enqueue(t *jobItem) {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, t)
}
func (s *jobsQueue) Dequeue(n int) []*jobItem {
s.lock.Lock()
defer s.lock.Unlock()
if n > s.size() {
n = s.size()
}
items := s.items[0:n]
s.items = s.items[n:len(s.items)]
return items
}
func (s *jobsQueue) Size() int {
s.lock.Lock()
defer s.lock.Unlock()
return s.size()
}
func (s *jobsQueue) size() int {
return len(s.items)
}
var usernamesToUuids = mojang.UsernamesToUuids
var forever = func() bool {
return true
}
type BatchUuidsProvider struct {
Emitter
IterationDelay time.Duration
IterationSize int
onFirstCall sync.Once
queue jobsQueue
}
func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
ctx.onFirstCall.Do(func() {
ctx.queue.New()
ctx.startQueue()
})
resultChan := make(chan *jobResult)
ctx.queue.Enqueue(&jobItem{username, resultChan})
ctx.Emit("mojang_textures:batch_uuids_provider:queued", username)
result := <-resultChan
return result.profile, result.error
}
func (ctx *BatchUuidsProvider) startQueue() {
go func() {
time.Sleep(ctx.IterationDelay)
for forever() {
ctx.Emit("mojang_textures:batch_uuids_provider:before_round")
ctx.queueRound()
ctx.Emit("mojang_textures:batch_uuids_provider:after_round")
time.Sleep(ctx.IterationDelay)
}
}()
}
func (ctx *BatchUuidsProvider) queueRound() {
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(ctx.IterationSize)
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.username)
}
ctx.Emit("mojang_textures:batch_uuids_provider:round", usernames, queueSize-len(jobs))
if len(usernames) == 0 {
return
}
profiles, err := usernamesToUuids(usernames)
ctx.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err)
for _, job := range jobs {
go func(job *jobItem) {
response := &jobResult{}
if err != nil {
response.error = err
} else {
// The profiles in the response aren't ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.username, profile.Name) {
response.profile = profile
break
}
}
}
job.respondChan <- response
}(job)
}
}

View File

@@ -0,0 +1,300 @@
package mojangtextures
import (
"crypto/rand"
"encoding/base64"
"strings"
"testing"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestJobsQueue(t *testing.T) {
createQueue := func() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}
t.Run("Enqueue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
assert.Equal(3, s.Size())
})
t.Run("Dequeue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
s.Enqueue(&jobItem{username: "username4"})
items := s.Dequeue(2)
assert.Len(items, 2)
assert.Equal("username1", items[0].username)
assert.Equal("username2", items[1].username)
assert.Equal(2, s.Size())
items = s.Dequeue(40)
assert.Len(items, 2)
assert.Equal("username3", items[0].username)
assert.Equal("username4", items[1].username)
})
}
// This is really stupid test just to get 100% coverage on this package :)
func TestBatchUuidsProvider_forever(t *testing.T) {
testify.True(t, forever())
}
type mojangUsernamesToUuidsRequestMock struct {
mock.Mock
}
func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(usernames []string) ([]*mojang.ProfileInfo, error) {
args := o.Called(usernames)
var result []*mojang.ProfileInfo
if casted, ok := args.Get(0).([]*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type batchUuidsProviderGetUuidResult struct {
Result *mojang.ProfileInfo
Error error
}
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
GetUuidAsync func(username string) chan *batchUuidsProviderGetUuidResult
Emitter *mockEmitter
MojangApi *mojangUsernamesToUuidsRequestMock
Iterate func()
done func()
iterateChan chan bool
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Provider = &BatchUuidsProvider{
Emitter: suite.Emitter,
IterationDelay: 0,
IterationSize: 10,
}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.GetUuidAsync = func(username string) chan *batchUuidsProviderGetUuidResult {
s := make(chan bool)
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before all usernames
// will be queued.
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
s <- true
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
usernamesToUuids = suite.MojangApi.UsernamesToUuids
}
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.done()
suite.Emitter.AssertExpectations(suite.T())
suite.MojangApi.AssertExpectations(suite.T())
}
func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForOneUsername() {
expectedUsernames := []string{"username"}
expectedResult := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResponse := []*mojang.ProfileInfo{expectedResult}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{expectedResult}, nil)
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result.Result)
suite.Assert().Nil(result.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
expectedUsernames := []string{"username1", "username2"}
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
expectedResponse := []*mojang.ProfileInfo{expectedResult1, expectedResult2}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
expectedResult2,
}, nil)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
result1 := <-resultChan1
suite.Assert().Equal(expectedResult1, result1.Result)
suite.Assert().Nil(result1.Error)
result2 := <-resultChan2
suite.Assert().Equal(expectedResult2, result2.Result)
suite.Assert().Nil(result2.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
// In this test we're not testing response, so always return an empty resultset
expectedResponse := []*mojang.ProfileInfo{}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[0:10], 2).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[0:10], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[10:12], 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[10:12], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Twice()
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return(expectedResponse, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return(expectedResponse, nil)
channels := make([]chan *batchUuidsProviderGetUuidResult, len(usernames))
for i, username := range usernames {
channels[i] = suite.GetUuidAsync(username)
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
}
func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Times(3)
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", []string{"username"}, mock.Anything, nil).Once()
var nilStringSlice []string
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", nilStringSlice, 0).Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Times(3)
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finishes
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Nil(result.Result)
suite.Assert().Nil(result.Error)
// Let it to perform a few more iterations to ensure, that there are no calls to external APIs
suite.Iterate()
suite.Iterate()
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError() {
expectedUsernames := []string{"username1", "username2"}
expectedError := &mojang.TooManyRequestsError{}
var nilProfilesResponse []*mojang.ProfileInfo
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, nilProfilesResponse, expectedError).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
result1 := <-resultChan1
suite.Assert().Nil(result1.Result)
suite.Assert().Equal(expectedError, result1.Error)
result2 := <-resultChan2
suite.Assert().Nil(result2.Result)
suite.Assert().Equal(expectedError, result2.Error)
}
var replacer = strings.NewReplacer("-", "_", "=", "")
// https://stackoverflow.com/a/50581165
func randStr(len int) string {
buff := make([]byte, len)
_, _ = rand.Read(buff)
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
// Base 64 can be longer than len
return str[:len]
}

View File

@@ -1,4 +1,4 @@
package queue
package mojangtextures
import (
"sync"
@@ -9,8 +9,6 @@ import (
"github.com/tevino/abool"
)
var inMemoryStorageGCPeriod = 10 * time.Second
var inMemoryStoragePersistPeriod = time.Minute + 10*time.Second
var now = time.Now
type inMemoryItem struct {
@@ -18,31 +16,38 @@ type inMemoryItem struct {
timestamp int64
}
type inMemoryTexturesStorage struct {
lock sync.Mutex
type InMemoryTexturesStorage struct {
GCPeriod time.Duration
Duration time.Duration
lock sync.RWMutex
data map[string]*inMemoryItem
working *abool.AtomicBool
}
func CreateInMemoryTexturesStorage() *inMemoryTexturesStorage {
return &inMemoryTexturesStorage{
data: make(map[string]*inMemoryItem),
func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
storage := &InMemoryTexturesStorage{
GCPeriod: 10 * time.Second,
Duration: time.Minute + 10*time.Second,
data: make(map[string]*inMemoryItem),
}
return storage
}
func (s *inMemoryTexturesStorage) Start() {
func (s *InMemoryTexturesStorage) Start() {
if s.working == nil {
s.working = abool.New()
}
if !s.working.IsSet() {
go func() {
time.Sleep(inMemoryStorageGCPeriod)
time.Sleep(s.GCPeriod)
// TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right
for s.working.IsSet() {
start := time.Now()
s.gc()
time.Sleep(inMemoryStorageGCPeriod - time.Since(start))
time.Sleep(s.GCPeriod - time.Since(start))
}
}()
}
@@ -50,16 +55,16 @@ func (s *inMemoryTexturesStorage) Start() {
s.working.Set()
}
func (s *inMemoryTexturesStorage) Stop() {
func (s *InMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *inMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.Lock()
defer s.lock.Unlock()
func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.RLock()
defer s.lock.RUnlock()
item, exists := s.data[uuid]
validRange := getMinimalNotExpiredTimestamp()
validRange := s.getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, &ValueNotFound{}
}
@@ -67,7 +72,7 @@ func (s *inMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTextur
return item.textures, nil
}
func (s *inMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
var timestamp int64
if textures != nil {
decoded := textures.DecodeTextures()
@@ -89,11 +94,11 @@ func (s *inMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.Si
}
}
func (s *inMemoryTexturesStorage) gc() {
func (s *InMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
maxTime := getMinimalNotExpiredTimestamp()
maxTime := s.getMinimalNotExpiredTimestamp()
for uuid, value := range s.data {
if maxTime > value.timestamp {
delete(s.data, uuid)
@@ -101,8 +106,8 @@ func (s *inMemoryTexturesStorage) gc() {
}
}
func getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(inMemoryStoragePersistPeriod * time.Duration(-1)).UnixNano())
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(s.Duration * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {

View File

@@ -1,4 +1,4 @@
package queue
package mojangtextures
import (
"time"
@@ -48,7 +48,7 @@ func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get error when uuid is not exists", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(result)
@@ -58,7 +58,7 @@ func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
@@ -69,7 +69,7 @@ func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
now = func() time.Time {
@@ -89,7 +89,7 @@ func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
@@ -100,7 +100,7 @@ func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("override already existed textures for uuid", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
@@ -113,7 +113,7 @@ func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
@@ -131,7 +131,7 @@ func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := CreateInMemoryTexturesStorage()
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
})
@@ -140,8 +140,9 @@ func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
inMemoryStorageGCPeriod = 10 * time.Millisecond
inMemoryStoragePersistPeriod = 10 * time.Millisecond
storage := NewInMemoryTexturesStorage()
storage.GCPeriod = 10 * time.Millisecond
storage.Duration = 10 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
@@ -150,7 +151,7 @@ func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
@@ -165,7 +166,7 @@ func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
@@ -174,13 +175,12 @@ func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
},
}
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let it start first iteration
time.Sleep(storage.GCPeriod + time.Millisecond) // Let it start first iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
@@ -188,7 +188,7 @@ func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let another iteration happen
time.Sleep(storage.GCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")

View File

@@ -0,0 +1,19 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
var uuidToTextures = mojang.UuidToTextures
type MojangApiTexturesProvider struct {
Emitter
}
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid)
result, err := uuidToTextures(uuid, true)
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", result, err)
return result, err
}

View File

@@ -0,0 +1,96 @@
package mojangtextures
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
type mojangUuidToTexturesRequestMock struct {
mock.Mock
}
func (o *mojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) {
args := o.Called(uuid, signed)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type mojangApiTexturesProviderTestSuite struct {
suite.Suite
Provider *MojangApiTexturesProvider
Emitter *mockEmitter
MojangApi *mojangUuidToTexturesRequestMock
}
func (suite *mojangApiTexturesProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.MojangApi = &mojangUuidToTexturesRequestMock{}
suite.Provider = &MojangApiTexturesProvider{
Emitter: suite.Emitter,
}
uuidToTextures = suite.MojangApi.UuidToTextures
}
func (suite *mojangApiTexturesProviderTestSuite) TearDownTest() {
suite.MojangApi.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func TestMojangApiTexturesProvider(t *testing.T) {
suite.Run(t, new(mojangApiTexturesProviderTestSuite))
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
expectedResult := &mojang.SignedTexturesResponse{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(expectedResult, nil)
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:before_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
expectedResult,
nil,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Equal(expectedResult, result)
suite.Assert().Nil(err)
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
var expectedResponse *mojang.SignedTexturesResponse
expectedError := &mojang.TooManyRequestsError{}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:before_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
expectedResponse,
expectedError,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedError, err)
}

View File

@@ -0,0 +1,195 @@
package mojangtextures
import (
"errors"
"regexp"
"strings"
"sync"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type broadcastResult struct {
textures *mojang.SignedTexturesResponse
error error
}
type broadcaster struct {
lock sync.Mutex
listeners map[string][]chan *broadcastResult
}
func createBroadcaster() *broadcaster {
return &broadcaster{
listeners: make(map[string][]chan *broadcastResult),
}
}
// Returns a boolean value, which will be true if the passed username didn't exist before
func (c *broadcaster) AddListener(username string, resultChan chan *broadcastResult) bool {
c.lock.Lock()
defer c.lock.Unlock()
val, alreadyHasSource := c.listeners[username]
if alreadyHasSource {
c.listeners[username] = append(val, resultChan)
return false
}
c.listeners[username] = []chan *broadcastResult{resultChan}
return true
}
func (c *broadcaster) BroadcastAndRemove(username string, result *broadcastResult) {
c.lock.Lock()
defer c.lock.Unlock()
val, ok := c.listeners[username]
if !ok {
return
}
for _, channel := range val {
go func(channel chan *broadcastResult) {
channel <- result
close(channel)
}(channel)
}
delete(c.listeners, username)
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
type UUIDsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type TexturesProvider interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
}
type Emitter interface {
dispatcher.Emitter
}
type Provider struct {
Emitter
UUIDsProvider
TexturesProvider
Storage
onFirstCall sync.Once
*broadcaster
}
func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
ctx.onFirstCall.Do(func() {
ctx.broadcaster = createBroadcaster()
})
if !allowedUsernamesRegex.MatchString(username) {
return nil, errors.New("invalid username")
}
username = strings.ToLower(username)
ctx.Emit("mojang_textures:call", username)
uuid, err := ctx.getUuidFromCache(username)
if err == nil && uuid == "" {
return nil, nil
}
if uuid != "" {
textures, err := ctx.getTexturesFromCache(uuid)
if err == nil {
return textures, nil
}
}
resultChan := make(chan *broadcastResult)
isFirstListener := ctx.broadcaster.AddListener(username, resultChan)
if isFirstListener {
go ctx.getResultAndBroadcast(username, uuid)
} else {
ctx.Emit("mojang_textures:already_processing", username)
}
result := <-resultChan
return result.textures, result.error
}
func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
ctx.Emit("mojang_textures:before_result", username, uuid)
result := ctx.getResult(username, uuid)
ctx.Emit("mojang_textures:after_result", username, result.textures, result.error)
ctx.broadcaster.BroadcastAndRemove(username, result)
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
if uuid == "" {
profile, err := ctx.getUuid(username)
if err != nil {
return &broadcastResult{nil, err}
}
uuid = ""
if profile != nil {
uuid = profile.Id
}
_ = ctx.Storage.StoreUuid(username, uuid)
if uuid == "" {
return &broadcastResult{nil, nil}
}
}
textures, err := ctx.getTextures(uuid)
if err != nil {
return &broadcastResult{nil, err}
}
// Mojang can respond with an error, but it will still count as a hit,
// therefore store the result even if textures is nil to prevent 429 error
ctx.Storage.StoreTextures(uuid, textures)
return &broadcastResult{textures, nil}
}
func (ctx *Provider) getUuidFromCache(username string) (string, error) {
ctx.Emit("mojang_textures:usernames:before_cache", username)
uuid, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, err)
return uuid, err
}
func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:textures:before_cache", uuid)
textures, err := ctx.Storage.GetTextures(uuid)
ctx.Emit("mojang_textures:textures:after_cache", uuid, textures, err)
return textures, err
}
func (ctx *Provider) getUuid(username string) (*mojang.ProfileInfo, error) {
ctx.Emit("mojang_textures:usernames:before_call", username)
profile, err := ctx.UUIDsProvider.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_call", username, profile, err)
return profile, err
}
func (ctx *Provider) getTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:textures:before_call", uuid)
textures, err := ctx.TexturesProvider.GetTextures(uuid)
ctx.Emit("mojang_textures:textures:after_call", uuid, textures, err)
return textures, err
}

View File

@@ -0,0 +1,406 @@
package mojangtextures
import (
"errors"
"sync"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestBroadcaster(t *testing.T) {
t.Run("GetOrAppend", func(t *testing.T) {
t.Run("first call when username didn't exist before should return true", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel)
assert.True(isFirstListener)
listeners, ok := broadcaster.listeners["mock"]
assert.True(ok)
assert.Len(listeners, 1)
assert.Equal(channel, listeners[0])
})
t.Run("subsequent calls should return false", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel1 := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel1)
assert.True(isFirstListener)
channel2 := make(chan *broadcastResult)
isFirstListener = broadcaster.AddListener("mock", channel2)
assert.False(isFirstListener)
channel3 := make(chan *broadcastResult)
isFirstListener = broadcaster.AddListener("mock", channel3)
assert.False(isFirstListener)
})
})
t.Run("BroadcastAndRemove", func(t *testing.T) {
t.Run("should broadcast to all listeners and remove the key", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel1 := make(chan *broadcastResult)
channel2 := make(chan *broadcastResult)
broadcaster.AddListener("mock", channel1)
broadcaster.AddListener("mock", channel2)
result := &broadcastResult{}
broadcaster.BroadcastAndRemove("mock", result)
assert.Equal(result, <-channel1)
assert.Equal(result, <-channel2)
channel3 := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel3)
assert.True(isFirstListener)
})
t.Run("call on not exists username", func(t *testing.T) {
assert := testify.New(t)
assert.NotPanics(func() {
broadcaster := createBroadcaster()
broadcaster.BroadcastAndRemove("mock", &broadcastResult{})
})
})
})
}
type mockEmitter struct {
mock.Mock
}
func (e *mockEmitter) Emit(name string, args ...interface{}) {
e.Called(append([]interface{}{name}, args...)...)
}
type mockUuidsProvider struct {
mock.Mock
}
func (m *mockUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
args := m.Called(username)
var result *mojang.ProfileInfo
if casted, ok := args.Get(0).(*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type mockTexturesProvider struct {
mock.Mock
}
func (m *mockTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type mockStorage struct {
mock.Mock
}
func (m *mockStorage) GetUuid(username string) (string, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
}
func (m *mockStorage) StoreUuid(username string, uuid string) error {
args := m.Called(username, uuid)
return args.Error(0)
}
func (m *mockStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
func (m *mockStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
m.Called(uuid, textures)
}
type providerTestSuite struct {
suite.Suite
Provider *Provider
Emitter *mockEmitter
UuidsProvider *mockUuidsProvider
TexturesProvider *mockTexturesProvider
Storage *mockStorage
}
func (suite *providerTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.UuidsProvider = &mockUuidsProvider{}
suite.TexturesProvider = &mockTexturesProvider{}
suite.Storage = &mockStorage{}
suite.Provider = &Provider{
Emitter: suite.Emitter,
UUIDsProvider: suite.UuidsProvider,
TexturesProvider: suite.TexturesProvider,
Storage: suite.Storage,
}
}
func (suite *providerTestSuite) TearDownTest() {
suite.Emitter.AssertExpectations(suite.T())
suite.UuidsProvider.AssertExpectations(suite.T())
suite.TexturesProvider.AssertExpectations(suite.T())
suite.Storage.AssertExpectations(suite.T())
}
func TestProvider(t *testing.T) {
suite.Run(t, new(providerTestSuite))
}
func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
var expectedCachedTextures *mojang.SignedTexturesResponse
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Nil(err)
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "").Once().Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
var expectedResult *mojang.SignedTexturesResponse
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Equal(expectedResult, result)
suite.Assert().Nil(err)
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Twice()
suite.Emitter.On("Emit", "mojang_textures:already_processing", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
// If possible, than remove this .After call
suite.UuidsProvider.On("GetUuid", "username").Once().After(time.Millisecond).Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
results := make([]*mojang.SignedTexturesResponse, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
textures, _ := suite.Provider.GetForUsername("username")
results[i] = textures
wg.Done()
}(i)
}
wg.Wait()
suite.Assert().Equal(expectedResult, results[0])
suite.Assert().Equal(expectedResult, results[1])
}
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
result, err := suite.Provider.GetForUsername("Not allowed")
suite.Assert().Error(err, "invalid username")
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
err := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
result, resErr := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(err, resErr)
}
func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
var expectedResult *mojang.SignedTexturesResponse
err := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
result, resErr := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(err, resErr)
}

View File

@@ -0,0 +1,12 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
type NilProvider struct {
}
func (p *NilProvider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
return nil, nil
}

View File

@@ -0,0 +1,14 @@
package mojangtextures
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNilProvider_GetForUsername(t *testing.T) {
provider := &NilProvider{}
result, err := provider.GetForUsername("username")
assert.Nil(t, result)
assert.Nil(t, err)
}

View File

@@ -0,0 +1,67 @@
package mojangtextures
import (
"encoding/json"
"io/ioutil"
"net/http"
. "net/url"
"path"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/version"
)
var HttpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: 1024,
},
}
type RemoteApiUuidsProvider struct {
Emitter
Url URL
}
func (ctx *RemoteApiUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
url := ctx.Url
url.Path = path.Join(url.Path, username)
urlStr := url.String()
request, _ := http.NewRequest("GET", urlStr, nil)
request.Header.Add("Accept", "application/json")
// Change default User-Agent to allow specify "Username -> UUID at time" Mojang's api endpoint
request.Header.Add("User-Agent", "Chrly/"+version.Version())
ctx.Emit("mojang_textures:remote_api_uuids_provider:before_request", urlStr)
response, err := HttpClient.Do(request)
ctx.Emit("mojang_textures:remote_api_uuids_provider:after_request", response, err)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode == 204 {
return nil, nil
}
if response.StatusCode != 200 {
return nil, &UnexpectedRemoteApiResponse{response}
}
var result *mojang.ProfileInfo
body, _ := ioutil.ReadAll(response.Body)
err = json.Unmarshal(body, &result)
if err != nil {
return nil, err
}
return result, nil
}
type UnexpectedRemoteApiResponse struct {
Response *http.Response
}
func (*UnexpectedRemoteApiResponse) Error() string {
return "Unexpected remote api response"
}

View File

@@ -0,0 +1,167 @@
package mojangtextures
import (
"net"
"net/http"
. "net/url"
"testing"
"github.com/h2non/gock"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
type remoteApiUuidsProviderTestSuite struct {
suite.Suite
Provider *RemoteApiUuidsProvider
Emitter *mockEmitter
}
func (suite *remoteApiUuidsProviderTestSuite) SetupSuite() {
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
}
func (suite *remoteApiUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Provider = &RemoteApiUuidsProvider{
Emitter: suite.Emitter,
}
}
func (suite *remoteApiUuidsProviderTestSuite) TearDownTest() {
suite.Emitter.AssertExpectations(suite.T())
gock.Off()
}
func TestRemoteApiUuidsProvider(t *testing.T) {
suite.Run(t, new(remoteApiUuidsProviderTestSuite))
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForValidUsername() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(200).
JSON(map[string]interface{}{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"name": "username",
})
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
if assert.NoError(err) {
assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", result.Id)
assert.Equal("username", result.Name)
assert.False(result.IsLegacy)
assert.False(result.IsDemo)
}
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotExistsUsername() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(204)
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
assert.Nil(err)
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNon20xResponse() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(504).
BodyString("504 Gateway Timeout")
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
assert.EqualError(err, "Unexpected remote api response")
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotSuccessRequest() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
mock.AnythingOfType("*url.Error"),
).Once()
expectedError := &net.OpError{Op: "dial"}
gock.New("http://example.com").
Get("/subpath/username").
ReplyError(expectedError)
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
if assert.Error(err) {
assert.IsType(&Error{}, err)
casterErr, _ := err.(*Error)
assert.Equal(expectedError, casterErr.Err)
}
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForInvalidSuccessResponse() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(200).
BodyString("completely not json")
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
assert.Error(err)
}
func shouldParseUrl(rawUrl string) URL {
url, err := Parse(rawUrl)
if err != nil {
panic(err)
}
return *url
}

61
mojangtextures/storage.go Normal file
View File

@@ -0,0 +1,61 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
// UuidsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// used to reduce the load on the account information queue
type UuidsStorage interface {
// Since only primitive types are used in this method, you should return a special error ValueNotFound
// to return the information that no error has occurred and username does not have uuid
GetUuid(username string) (string, error)
// An empty uuid value can be passed if the corresponding account has not been found
StoreUuid(username string, uuid string) error
}
// TexturesStorage is a Mojang's textures storage, used as a values cache to avoid 429 errors
type TexturesStorage interface {
// Error should not have nil value only if the repository failed to determine if there are any textures
// for this uuid or not at all. If there is information about the absence of textures, nil nil should be returned
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
// The nil value can be passed when there are no textures for the corresponding uuid and we know about it
StoreTextures(uuid string, textures *mojang.SignedTexturesResponse)
}
type Storage interface {
UuidsStorage
TexturesStorage
}
// SeparatedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SeparatedStorage struct {
UuidsStorage
TexturesStorage
}
func (s *SeparatedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
}
func (s *SeparatedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
}
func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
return s.TexturesStorage.GetTextures(uuid)
}
func (s *SeparatedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -1,4 +1,4 @@
package queue
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
@@ -41,11 +41,11 @@ func (m *texturesStorageMock) StoreTextures(uuid string, textures *mojang.Signed
}
func TestSplittedStorage(t *testing.T) {
createMockedStorage := func() (*SplittedStorage, *uuidsStorageMock, *texturesStorageMock) {
createMockedStorage := func() (*SeparatedStorage, *uuidsStorageMock, *texturesStorageMock) {
uuidsStorage := &uuidsStorageMock{}
texturesStorage := &texturesStorageMock{}
return &SplittedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage
return &SeparatedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage
}
t.Run("GetUuid", func(t *testing.T) {

View File

@@ -1,46 +0,0 @@
#!/bin/sh
# Based on https://github.com/mlafeldt/chef-runner/blob/34269dbb726c243dff9764007e7bd7f0fe9ee331/script/coverage
# Generate test coverage statistics for Go packages.
#
# Works around the fact that `go test -coverprofile` currently does not work
# with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909
#
# Usage: script/coverage [--html]
#
# --html Additionally create HTML report and open it in browser
#
set -e
workdir=.cover
profile="$workdir/cover.out"
mode=count
generate_cover_data() {
rm -rf "$workdir"
mkdir "$workdir"
go test -i "$@" # compile dependencies first before serializing go test invocations
for pkg in "$@"; do
f="$workdir/$(echo $pkg | tr / -).cover"
go test -covermode="$mode" -coverprofile="$f" "$pkg"
done
echo "mode: $mode" >"$profile"
grep -h -v "^mode:" "$workdir"/*.cover >>"$profile"
}
show_cover_report() {
go tool cover -${1}="$profile"
}
generate_cover_data $(go list ./... | grep -v /vendor/)
show_cover_report func
case "$1" in
"")
;;
--html)
show_cover_report html ;;
*)
echo >&2 "error: invalid option: $1"; exit 1 ;;
esac

View File

@@ -1,4 +0,0 @@
#!/bin/sh
mockgen -source=interfaces/repositories.go -destination=interfaces/mock_interfaces/mock_interfaces.go
mockgen -source=interfaces/auth.go -destination=interfaces/mock_interfaces/mock_auth.go

View File

@@ -1,27 +0,0 @@
#!/bin/sh
# Based on https://github.com/mlafeldt/chef-runner/blob/34269dbb726c243dff9764007e7bd7f0fe9ee331/script/test
# Run package tests for a file/directory, or all tests if no argument is passed.
# Useful to e.g. execute package tests for the file currently open in Vim.
# Usage: script/test [path]
set -e
go_pkg_from_path() {
path=$1
if test -d "$path"; then
dir="$path"
else
dir=$(dirname "$path")
fi
(cd "$dir" && go list)
}
if test $# -gt 0; then
pkg=$(go_pkg_from_path "$1")
verbose=-v
else
pkg=$(go list ./... | grep -v /vendor/)
verbose=
fi
exec go test ${GOTESTOPTS:-$verbose} $pkg

View File

@@ -1,33 +0,0 @@
package tests
import (
"github.com/elyby/chrly/api/mojang"
"github.com/stretchr/testify/mock"
)
type MojangTexturesQueueMock struct {
mock.Mock
}
func (m *MojangTexturesQueueMock) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse {
args := m.Called(username)
result := make(chan *mojang.SignedTexturesResponse)
arg := args.Get(0)
switch arg.(type) {
case *mojang.SignedTexturesResponse:
go func() {
result <- arg.(*mojang.SignedTexturesResponse)
}()
case chan *mojang.SignedTexturesResponse:
return arg.(chan *mojang.SignedTexturesResponse)
case nil:
go func() {
result <- nil
}()
default:
panic("unsupported return value")
}
return result
}

View File

@@ -1,61 +0,0 @@
package tests
import (
"time"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/stretchr/testify/mock"
)
type WdMock struct {
mock.Mock
}
func (m *WdMock) Trace(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Debug(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Info(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Warning(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Error(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Alert(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Emergency(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) IncCounter(name string, value int64, p ...slf.Param) {
m.Called(name, value)
}
func (m *WdMock) UpdateGauge(name string, value int64, p ...slf.Param) {
m.Called(name, value)
}
func (m *WdMock) RecordTimer(name string, d time.Duration, p ...slf.Param) {
m.Called(name, d)
}
func (m *WdMock) Timer(name string, p ...slf.Param) slf.Timer {
return slf.NewTimer(name, p, m)
}
func (m *WdMock) WithParams(p ...slf.Param) wd.Watchdog {
panic("this method shouldn't be used")
}

14
version/version.go Normal file
View File

@@ -0,0 +1,14 @@
package version
var (
version = ""
commit = ""
)
func Version() string {
return version
}
func Commit() string {
return commit
}