Compare commits

..

139 Commits

Author SHA1 Message Date
ErickSkrauch
cfe8fea3f7
Add signature to the custom profile property when ?unsigned=false 2024-07-09 18:37:47 +02:00
ErickSkrauch
27c7b79b32
Added onUnknownProfileRespondWithUuid param to the /profile endpoint
Introducing profiles endpoint was a mistake, but we had to deal with that mistake until I'll remove it. The Accounts service needs textures with a signature. But it is possible that a user has a fresh account and Chrly has not yet received the profile information. In this case we have no way to get textures for the player. Adding the onUnknownProfileRespondWithUuid parameter solves this problem. This is a bad solution and nobody should use it except Ely.by infrastructure. In v5 version the texture signature on Chrly will be removed.
2024-06-11 02:31:47 +02:00
ErickSkrauch
ad31fdb709
Quick fix for production data inconsistency 2023-12-22 18:45:38 +01:00
ErickSkrauch
fa62d45d00
Update mojang api username filter 2023-12-22 02:00:31 +01:00
ErickSkrauch
cadb89f00a
Fixes #40. Allow to upload profile information without a skin
Remove skin file uploading stubs
2023-12-22 01:56:02 +01:00
ErickSkrauch
20ba78953b
Handle absence of the additional reporters 2023-12-14 03:01:55 +01:00
ErickSkrauch
883a7bda3c
Fixes #8. Replace radix v2 with v4 2023-12-14 02:16:24 +01:00
ErickSkrauch
d678f61df7
Upgrade dependencies 2023-12-14 02:16:24 +01:00
ErickSkrauch
1543e98b87
Restore codecov export and update README badges 2023-12-13 17:09:46 +01:00
ErickSkrauch
0e0b41d6d7
Update LICENSE 2023-12-13 15:11:07 +01:00
ErickSkrauch
3cd12acc1b
Export profile requests metrics to statsd 2023-12-13 01:56:40 +01:00
ErickSkrauch
dac4ed0ac6
Update CHANGELOG 2023-12-13 01:51:22 +01:00
ErickSkrauch
11a779c670
Another version CI fix 2023-12-13 01:34:15 +01:00
ErickSkrauch
6cb5e1eb42
Fix version generation 2023-12-13 01:23:53 +01:00
ErickSkrauch
8959e53270
Generate version and commit refs for built docker image 2023-12-13 01:09:21 +01:00
ErickSkrauch
3526570dd3
Run release only when previous step succeeded 2023-12-12 02:37:52 +01:00
ErickSkrauch
4b7f1346f5
Add an edge tag for master releases 2023-12-12 02:30:27 +01:00
ErickSkrauch
e7721f9e5a
Enable pushing 2023-12-12 02:21:22 +01:00
ErickSkrauch
26bfbd1517
Read secrets from secrets 2023-12-12 02:13:19 +01:00
ErickSkrauch
ecbb06b83c
Fix release pipeline and upgrade Dockerfile 2023-12-12 02:06:00 +01:00
ErickSkrauch
6accffed45
Replace dep with go mod, migrate from travis to github-actions 2023-12-12 01:35:08 +01:00
ErickSkrauch
980c920ceb Prepare 4.6.0 release 2021-03-04 07:03:07 +01:00
ErickSkrauch
32a9fee3e6 Replace /signature-verification-key endpoint with extension-specific ones /signature-verification-key.{der|pem}.
Fix Content-Disposition header
2021-03-03 13:33:56 +01:00
ErickSkrauch
7cf5ae13be Update Changelog [skip ci] 2021-03-03 01:38:47 +01:00
ErickSkrauch
98d280240e Resolves #28. We didn't manage to catch this error during testing, so we're normalizing its handling and hope that this will not happen again 2021-03-03 01:32:38 +01:00
ErickSkrauch
26042037b6 Increase Mojang's API response timeout 2021-03-03 01:23:34 +01:00
ErickSkrauch
1e3307dcbe Fixes CHRLY-S. Don't return an error for an invalid username 2021-03-03 00:24:31 +01:00
ErickSkrauch
d1d2c7ee6e Merge branch 'sign_textures' 2021-02-27 02:40:13 +01:00
ErickSkrauch
2bc9f8eb57 Rename the signature key param.
Rename the signature verification key endpoint.
Update CHANGELOG and README files
2021-02-27 02:37:59 +01:00
ErickSkrauch
6f148a8791 Implemented /profile/{username} endpoint to get complete profile with signed by the current server textures.
Implemented /signing-key endpoint to get public key in der format, used to sign the textures.
Improved logging of errors from http package.
Changed behavior of the /cloaks endpoint
2021-02-26 02:45:45 +01:00
ErickSkrauch
247499df6a
Fixes #29. If a previously cached UUID no longer exists, it will be invalidated and re-requested 2021-02-07 21:19:01 +01:00
ErickSkrauch
3bf6872f3e
Resolves CHRLY-H. Add debug statement to investigate #28 2021-02-07 16:47:21 +01:00
ErickSkrauch
60774b6b72
Resolves #27. Replace import of the gock with gopkg.in/h2non/gock.v1 2020-08-21 18:12:31 +03:00
ErickSkrauch
37cc8cda32 Prepare 4.5.0 release 2020-05-01 21:38:18 +03:00
ErickSkrauch
620bb95c74 Fix in_memory_textures_storage_test [skip deploy] 2020-05-01 20:11:49 +03:00
ErickSkrauch
fd05220299 Ensure that queue for Mojang textures provider is initialized before any job will be scheduled 2020-05-01 17:36:37 +03:00
ErickSkrauch
dfe024756e Fix default redis pool size value 2020-05-01 03:57:22 +03:00
ErickSkrauch
66ef76ce6d Handle SIGTERM as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker 2020-05-01 03:06:45 +03:00
ErickSkrauch
aabf54e318 Added new stats reporter to check suitable redis pool size 2020-05-01 02:46:12 +03:00
ErickSkrauch
5dbe6af1d0 Added --cpuprofile flag for the dev Docker images 2020-05-01 00:06:56 +03:00
ErickSkrauch
4c21fc5c90 Implemented health checker for textures provider from Mojang's API 2020-04-30 23:16:22 +03:00
ErickSkrauch
2ea094bbf6 Really fix usernames cache hit events 2020-04-30 00:44:31 +03:00
ErickSkrauch
c4566a337b Rework in_memory_textures_storage. Handle empty properties correctly 2020-04-30 00:24:41 +03:00
ErickSkrauch
05c68c6ba6 Fixes CHRLY-B. Handle the case when the textures property is not presented in Mojang's response 2020-04-29 21:54:40 +03:00
ErickSkrauch
8001eab9db Add rough sentry reporting to catch panic in the mojang textures decoder 2020-04-29 21:15:13 +03:00
ErickSkrauch
33b286cba0 Improve test case for redis.GetUuid 2020-04-28 18:13:01 +03:00
ErickSkrauch
f997fdf9b0 Resolves #26. Rework UUIDs storage interface to simplify results handling 2020-04-28 17:57:51 +03:00
ErickSkrauch
be30c23823 Merge branch '4.5.0' 2020-04-26 22:06:59 +03:00
ErickSkrauch
f43c1a9a37 Resolves #23. Allow to spoof Mojang's API addresses 2020-04-26 21:56:03 +03:00
ErickSkrauch
585318d307 Another attempt to fix FullBus test 2020-04-26 21:05:54 +03:00
ErickSkrauch
b2e501af60 Fix FullBus test 2020-04-26 20:58:46 +03:00
ErickSkrauch
d8f6786c69
Merge pull request #25 from elyby/24_batch_uuids_provider_strategies
FullBus stategy
2020-04-26 18:00:48 +03:00
ErickSkrauch
30c095525c Update README and CHANGELOG 2020-04-26 17:55:02 +03:00
ErickSkrauch
436d98e1a0 Fix stats reporting for batch UUIDs provider 2020-04-26 16:34:46 +03:00
ErickSkrauch
1b9e943c0e
Fixed strategies implementations, added tests 2020-04-26 03:48:23 +03:00
ErickSkrauch
29b6bc89b3
Extracted strategy from batch uuids provider implementation.
Reimplemented Periodic strategy.
Implemented FullBus strategy (#24).
Started working on tests.
2020-04-24 19:38:37 +03:00
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 d79e765bb0fb0955f5074e5f496e76eb27574c3e
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
107 changed files with 8586 additions and 4712 deletions

53
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Build
on:
push:
branches:
- '**'
tags:
- '*.*.*'
pull_request:
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
cache-dependency-path: go.sum
go-version-file: go.mod
- name: Install dependencies
run: go get .
- name: Go Format
run: gofmt -s -w . && git diff --exit-code
- name: Go Vet
run: go vet ./...
- name: Go Test
run: go test -v -race --tags redis -coverprofile=coverage.txt -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4-beta
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Build
run: go build ./...

66
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,66 @@
name: Release
on:
workflow_run:
workflows:
- Build
types:
- completed
branches:
- master
jobs:
dockerhub:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: meta
name: Docker meta
uses: docker/metadata-action@v5
with:
images: ${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=edge,branch=${{ github.event.repository.default_branch }}
- id: version
name: Set up build version
run: |
if [[ $GITHUB_REF_TYPE == "tag" ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
BRANCH_NAME=${GITHUB_REF#refs/heads/}
SHORT_SHA=$(git rev-parse --short $GITHUB_SHA)
VERSION="${BRANCH_NAME}-${SHORT_SHA}"
fi
echo "### Version: $VERSION" >> $GITHUB_STEP_SUMMARY
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push
uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.version }}
COMMIT=${{ github.sha }}

View File

@ -1,42 +0,0 @@
sudo: required
language: go
go:
- 1.12
services:
- docker
stages:
- test
- name: deploy
if: branch = master OR tag IS present
install:
- go get -u github.com/golang/dep/cmd/dep
- dep ensure
jobs:
include:
- stage: test
script:
- go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- bash <(curl -s https://codecov.io/bash)
- stage: deploy
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

View File

@ -5,6 +5,124 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - xxxx-xx-xx
### Added
- Allow to remove a skin without removing all user information
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.profiles.request`
### Fixed
- Adjusted Mojang usernames filter to be stickier according to their docs
- `/profile/{username}` endpoint now returns the correct signature for the custom property as well.
### Changed
- Bumped Go version to 1.21.
### Removed
- Removed mentioning and processing of skin uploading as a file, as this functionality was never implemented and was not planned to be implemented
- StatsD metrics:
- Gauges:
- `ely.skinsystem.{hostname}.app.redis.pool.available`
## [4.6.0] - 2021-03-04
### Added
- `/profile/{username}` endpoint, which returns a profile and its textures, equivalent of the Mojang's
[UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape).
- `/signature-verification-key.der` and `/signature-verification-key.pem` endpoints, which returns the public key in
`DER` or `PEM` formats for signature verification.
### Fixed
- [#28](https://github.com/elyby/chrly/issues/28): Added handling of corrupted data from the Mojang's username to UUID
cache.
- [#29](https://github.com/elyby/chrly/issues/29): If a previously cached UUID no longer exists,
it will be invalidated and re-requested.
- Use correct status code for error about empty response from Mojang's API.
### Changed
- **BREAKING**: `/cloaks/{username}` and `/textures/{username}` endpoints will no longer return a cape if there are no
textures for the requested username.
- All endpoints are now returns `500` status code when an error occurred during request processing.
- Increased the response timeout for Mojang's API from 3 to 10 seconds.
## [4.5.0] - 2020-05-01
### Added
- [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of
Mojang UUIDs: `full-bus`.
- New configuration param `QUEUE_STRATEGY` with the default value `periodic`.
- New configuration params: `MOJANG_API_BASE_URL` and `MOJANG_SESSION_SERVER_BASE_URL`, that allow you to spoof
Mojang API base addresses.
- New health checker, that ensures that response for textures provider from Mojang's API is valid.
- `dev` Docker images now have the `--cpuprofile` flag, which allows you to run the program with CPU profiling.
- New StatsD metrics:
- Gauges:
- `ely.skinsystem.{hostname}.app.redis.pool.available`
### Fixed
- Handle the case when there is no textures property in Mojang's response.
- Handle `SIGTERM` as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker.
- Default connections pool size for Redis.
### Changed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time` timer will not be recorded if the iteration was
empty.
## [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
@ -66,7 +184,12 @@ 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.3...HEAD
[Unreleased]: https://github.com/elyby/chrly/compare/4.6.0...HEAD
[4.6.0]: https://github.com/elyby/chrly/compare/4.5.0...4.6.0
[4.5.0]: https://github.com/elyby/chrly/compare/4.4.1...4.5.0
[4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1
[4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0
[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

View File

@ -1,14 +1,29 @@
FROM alpine:3.9.3
# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder
ARG VERSION=unversioned
ARG COMMIT=unspecified
COPY . /build
WORKDIR /build
RUN go mod download
RUN CGO_ENABLED=0 \
go build \
-trimpath \
-ldflags "-w -s -X github.com/elyby/chrly/version.version=$VERSION -X github.com/elyby/chrly/version.commit=$COMMIT" \
-o chrly \
main.go
FROM alpine:3.19
EXPOSE 80
RUN apk add --no-cache ca-certificates
ENV STORAGE_REDIS_HOST=redis
ENV STORAGE_FILESYSTEM_HOST=/data
COPY docker-entrypoint.sh /usr/local/bin/
COPY release/chrly /usr/local/bin/
COPY docker-entrypoint.sh /
COPY --from=builder /build/chrly /usr/local/bin/chrly
ENTRYPOINT ["docker-entrypoint.sh"]
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["serve"]

334
Gopkg.lock generated
View File

@ -1,334 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
digest = "1:8855efc2aff3afd6319da41b22a8ca1cfd1698af05a24852c01636ba65b133f0"
name = "github.com/SermoDigital/jose"
packages = [
".",
"crypto",
"jws",
"jwt",
]
pruneopts = ""
revision = "f6df55f235c24f236d11dbcf665249a59ac2021f"
version = "1.1"
[[projects]]
digest = "1:c7b11da9bf0707e6920e1b361fbbbbe9b277ef3a198377baa4527f6e31049be0"
name = "github.com/certifi/gocertifi"
packages = ["."]
pruneopts = ""
revision = "3fd9e1adb12b72d2f3f82191d49be9b93c69f67c"
version = "2017.07.27"
[[projects]]
digest = "1:56c130d885a4aacae1dd9c7b71cfe39912c7ebc1ff7d2b46083c8812996dc43b"
name = "github.com/davecgh/go-spew"
packages = ["spew"]
pruneopts = ""
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
digest = "1:9f1e571696860f2b4f8a241b43ce91c6085e7aaed849ccca53f590a4dc7b95bd"
name = "github.com/fsnotify/fsnotify"
packages = ["."]
pruneopts = ""
revision = "629574ca2a5df945712d3079857300b5e4da0236"
version = "v1.4.2"
[[projects]]
branch = "master"
digest = "1:904b0b847f705de43c15e6c8f3dd639044db5601dedfb2f3fdb3021a28491d15"
name = "github.com/getsentry/raven-go"
packages = ["."]
pruneopts = ""
revision = "919484f041ea21e7e27be291cee1d6af7bc98864"
[[projects]]
digest = "1:530233672f656641b365f8efb38ed9fba80e420baff2ce87633813ab3755ed6d"
name = "github.com/golang/mock"
packages = ["gomock"]
pruneopts = ""
revision = "51421b967af1f557f93a59e0057aaf15ca02e29c"
version = "v1.2.0"
[[projects]]
digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2"
name = "github.com/gorilla/mux"
packages = ["."]
pruneopts = ""
revision = "c5c6c98bc25355028a63748a498942a6398ccd22"
version = "v1.7.1"
[[projects]]
digest = "1:5eeb4bfc6db411dbb34a6d9e5d49a9956b160d59fd004ee8f03fe53c9605c082"
name = "github.com/h2non/gock"
packages = ["."]
pruneopts = ""
revision = "ba88c4862a27596539531ce469478a91bc5a0511"
version = "v1.0.14"
[[projects]]
digest = "1:0f31ddb2589297fc1d716f45b34e34bff34e968de1aa239543274c87522e86f4"
name = "github.com/h2non/parth"
packages = ["."]
pruneopts = ""
revision = "b4df798d65426f8c8ab5ca5f9987aec5575d26c9"
version = "v2.0.1"
[[projects]]
branch = "master"
digest = "1:8017a99c7fa4dac90c7a34e08d5f890043fc27e91e561f3267a09f65595b158c"
name = "github.com/hashicorp/hcl"
packages = [
".",
"hcl/ast",
"hcl/parser",
"hcl/printer",
"hcl/scanner",
"hcl/strconv",
"hcl/token",
"json/parser",
"json/scanner",
"json/token",
]
pruneopts = ""
revision = "8f6b1344a92ff8877cf24a5de9177bf7d0a2a187"
[[projects]]
digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be"
name = "github.com/inconshreveable/mousetrap"
packages = ["."]
pruneopts = ""
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
version = "v1.0"
[[projects]]
digest = "1:1ce378ab2352c756c6d7a0172c22ecbd387659d32712a4ce3bc474273309a5dc"
name = "github.com/magiconair/properties"
packages = ["."]
pruneopts = ""
revision = "be5ece7dd465ab0765a9682137865547526d1dfb"
version = "v1.7.3"
[[projects]]
branch = "master"
digest = "1:19a9f4143462f07553e9bf6ae0f1b8633a2c44763b1df90d4e9e49f51cd8423a"
name = "github.com/mediocregopher/radix.v2"
packages = [
"cluster",
"pool",
"redis",
"util",
]
pruneopts = ""
revision = "b67df6e626f993b64b3ca9f4b8630900e61002e3"
[[projects]]
branch = "master"
digest = "1:c9ede10a9ded782d25d1f0be87c680e11409c23554828f19a19d691a95e76130"
name = "github.com/mitchellh/mapstructure"
packages = ["."]
pruneopts = ""
revision = "d0303fe809921458f417bcf828397a65db30a7e4"
[[projects]]
branch = "master"
digest = "1:c62e653e0a78bcf08fd56c764e5725e604693ffbd35b2b283b360f174d073a75"
name = "github.com/mono83/slf"
packages = [
".",
"filters",
"params",
"rays",
"recievers",
"recievers/sentry",
"recievers/statsd",
"recievers/writer",
"wd",
]
pruneopts = ""
revision = "79153e9636db86e1c6b74d74dd04176f257a4f2d"
[[projects]]
branch = "master"
digest = "1:270261c28f6e71a8a31b9d308ec9145147040fd80d21563307767a8e844bfabc"
name = "github.com/mono83/udpwriter"
packages = ["."]
pruneopts = ""
revision = "a064bd7e3acfda563ea680b913b9ef24b7a73e15"
[[projects]]
digest = "1:049b5bee78dfdc9628ee0e557219c41f683e5b06c5a5f20eaba0105ccc586689"
name = "github.com/pelletier/go-buffruneio"
packages = ["."]
pruneopts = ""
revision = "c37440a7cf42ac63b919c752ca73a85067e05992"
version = "v0.2.0"
[[projects]]
digest = "1:6d5a9728ae27e477a07bb69f02ea0bade74eb8b0c7346d046337904bbf7af065"
name = "github.com/pelletier/go-toml"
packages = ["."]
pruneopts = ""
revision = "5ccdfb18c776b740aecaf085c4d9a2779199c279"
version = "v1.0.0"
[[projects]]
digest = "1:256484dbbcd271f9ecebc6795b2df8cad4c458dd0f5fd82a8c2fa0c29f233411"
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
pruneopts = ""
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
branch = "master"
digest = "1:c189f11a84aa8b868a4b7cd4605653160424ab299cf7cfb1c5bd2740b949928f"
name = "github.com/spf13/afero"
packages = [
".",
"mem",
]
pruneopts = ""
revision = "ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b"
[[projects]]
digest = "1:6ff9b74bfea2625f805edec59395dc37e4a06458dd3c14e3372337e3d35a2ed6"
name = "github.com/spf13/cast"
packages = ["."]
pruneopts = ""
revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4"
version = "v1.1.0"
[[projects]]
digest = "1:a1403cc8a94b8d7956ee5e9694badef0e7b051af289caad1cf668331e3ffa4f6"
name = "github.com/spf13/cobra"
packages = ["."]
pruneopts = ""
revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385"
version = "v0.0.3"
[[projects]]
branch = "master"
digest = "1:5cb42b990db5dc48b8bc23b6ee77b260713ba3244ca495cd1ed89533dc482a49"
name = "github.com/spf13/jwalterweatherman"
packages = ["."]
pruneopts = ""
revision = "12bd96e66386c1960ab0f74ced1362f66f552f7b"
[[projects]]
digest = "1:cbaf13cdbfef0e4734ed8a7504f57fe893d471d62a35b982bf6fb3f036449a66"
name = "github.com/spf13/pflag"
packages = ["."]
pruneopts = ""
revision = "298182f68c66c05229eb03ac171abe6e309ee79a"
version = "v1.0.3"
[[projects]]
digest = "1:90fe60ab6f827e308b0c8cc1e11dce8ff1e96a927c8b171271a3cb04dd517606"
name = "github.com/spf13/viper"
packages = ["."]
pruneopts = ""
revision = "9e56dacc08fbbf8c9ee2dbc717553c758ce42bc9"
version = "v1.3.2"
[[projects]]
digest = "1:711eebe744c0151a9d09af2315f0bb729b2ec7637ef4c410fa90a18ef74b65b6"
name = "github.com/stretchr/objx"
packages = ["."]
pruneopts = ""
revision = "477a77ecc69700c7cdeb1fa9e129548e1c1c393c"
version = "v0.1.1"
[[projects]]
digest = "1:381bcbeb112a51493d9d998bbba207a529c73dbb49b3fd789e48c63fac1f192c"
name = "github.com/stretchr/testify"
packages = [
"assert",
"mock",
"require",
"suite",
]
pruneopts = ""
revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053"
version = "v1.3.0"
[[projects]]
branch = "master"
digest = "1:86e6712cfd4070a2120c03fcec41cfcbbc51813504a74e28d74479edfaf669ee"
name = "github.com/tevino/abool"
packages = ["."]
pruneopts = ""
revision = "9b9efcf221b50905aab9bbabd3daed56dc10f339"
[[projects]]
digest = "1:061754b9de261d8e1cf804970dff7b3e105d1cb4883ef446dbe911489ba8e9eb"
name = "github.com/thedevsaddam/govalidator"
packages = ["."]
pruneopts = ""
revision = "0413a0eb80cac8ab2d666639130658ce49a0c967"
version = "v1.9.6"
[[projects]]
branch = "master"
digest = "1:19adc71218d62052cd18b83ebaab77961378876094081f4b1581ca28ef80395d"
name = "golang.org/x/sys"
packages = ["unix"]
pruneopts = ""
revision = "7ddbeae9ae08c6a06a59597f0c9edbc5ff2444ce"
[[projects]]
branch = "master"
digest = "1:653926785eac385fd1d61dc16360a5194c68d4bf2541234363a9375d2e88a039"
name = "golang.org/x/text"
packages = [
"internal/gen",
"internal/triegen",
"internal/ucd",
"transform",
"unicode/cldr",
"unicode/norm",
]
pruneopts = ""
revision = "bd91bbf73e9a4a801adbfb97133c992678533126"
[[projects]]
branch = "v2"
digest = "1:81314a486195626940617e43740b4fa073f265b0715c9f54ce2027fee1cb5f61"
name = "gopkg.in/yaml.v2"
packages = ["."]
pruneopts = ""
revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/SermoDigital/jose/crypto",
"github.com/SermoDigital/jose/jws",
"github.com/getsentry/raven-go",
"github.com/golang/mock/gomock",
"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/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/cobra",
"github.com/spf13/viper",
"github.com/stretchr/testify/assert",
"github.com/stretchr/testify/mock",
"github.com/stretchr/testify/suite",
"github.com/tevino/abool",
"github.com/thedevsaddam/govalidator",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@ -1,51 +0,0 @@
ignored = ["github.com/elyby/chrly"]
[[constraint]]
name = "github.com/gorilla/mux"
version = "^1.6.1"
[[constraint]]
name = "github.com/mediocregopher/radix.v2"
branch = "master"
[[constraint]]
name = "github.com/mono83/slf"
branch = "master"
[[constraint]]
name = "github.com/spf13/cobra"
version = "^0.0.3"
[[constraint]]
name = "github.com/spf13/viper"
version = "^1.0.0"
[[constraint]]
name = "github.com/getsentry/raven-go"
branch = "master"
[[constraint]]
name = "github.com/SermoDigital/jose"
version = "~1.1.0"
[[constraint]]
name = "github.com/thedevsaddam/govalidator"
version = "^1.9.6"
[[constraint]]
name = "github.com/tevino/abool"
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"

View File

@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Ely.by (http://ely.by)
Copyright 2023 Ely.by (https://ely.by)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

291
README.md
View File

@ -14,8 +14,9 @@ production ready.
## Installation
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` environment variable
that you must set before running `docker-compose up -d`. Other possible variables are described below.
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY`
environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
below.
```yml
version: '2'
@ -32,6 +33,7 @@ services:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
redis:
image: redis:4.0-32bit
@ -40,6 +42,11 @@ services:
- ./data/redis:/data
```
**Tip**: to generate a value for the `CHRLY_SIGNING_KEY` use the command below and then join it with a `base64:` prefix.
```sh
openssl genrsa 4096 | base64 -w0
```
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations.
@ -47,27 +54,140 @@ the host machine to do not lose data on container recreations.
Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key
inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated.
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `stop`
and `up` it:
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `up`
it again:
```sh
docker-compose stop app
docker-compose up -d app
```
**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_STRATEGY</td>
<td>
Sets the strategy for the queue in the batch provider of Mojang UUIDs. Allowed values are <code>periodic</code>
and <code>full-bus</code> (see <a href="https://github.com/elyby/chrly/issues/24">#24</a>).
</td>
<td><code>periodic</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>MOJANG_API_BASE_URL</td>
<td>
Allows you to spoof the Mojang's API server address.
</td>
<td><code>https://api.mojang.com</code></td>
</tr>
<tr>
<td>MOJANG_SESSION_SERVER_BASE_URL</td>
<td>
Allows you to spoof the Mojang's Session server address.
</td>
<td><code>https://sessionserver.mojang.com</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`.
## Endpoints
Each endpoint that accepts `username` as a part of an url takes it case insensitive. `.png` part can be omitted too.
Each endpoint that accepts `username` as a part of an url takes it case-insensitive. The `.png` postfix can be omitted.
#### `GET /skins/{username}.png`
@ -105,11 +225,76 @@ That request is handy in case when your server implements authentication for a g
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
to the Chrly server and put the result in your hasJoined response.
#### `GET /profile/{username}`
This endpoint behaves exactly like the
[Mojang's UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape), but using
a username instead of the UUID. Just like in the Mojang's API, you can append `?unsigned=false` part to URL to sign
the `textures` property. If the textures for the requested username aren't found, it'll request them through the
Mojang's API, but the Mojang's signature will be discarded and the textures will be re-signed using the signature key
for your Chrly instance.
Response example:
```json
{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "username",
"properties": [
{
"name": "textures",
"signature": "textures signature value",
"value": "base64 encoded value"
},
{
"name": "chrly",
"signature": "custom property signature value",
"value": "how do you tame a horse in Minecraft?"
}
]
}
```
The base64 `value` string for the `textures` property decoded:
```json
{
"timestamp": 1614387238630,
"profileId": "0f657aa8bfbe415db7005750090d3af3",
"profileName": "username",
"textures": {
"SKIN": {
"url": "http://example.com/skin.png"
},
"CAPE": {
"url": "http://example.com/cape.png"
}
}
}
```
If username can't be found locally and can't be obtained from the Mojang's API, empty response with `204` status code
will be sent.
Note that this endpoint will try to use the UUID for the stored profile in the database. This is an edge case, related
to the situation where the user is available in the database but has no textures, which caused them to be retrieved
from the Mojang's API.
#### `GET /signature-verification-key.der`
This endpoint returns a public key that can be used to verify textures signatures. The key is provided in `DER` format,
so it can be used directly in the Authlib, without modifying the signature checking algorithm.
#### `GET /signature-verification-key.pem`
The same endpoint as the previous one, except that it returns the key in `PEM` format.
#### `GET /textures/signed/{username}`
Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if
you have your own source of Mojang's signatures, then you can pass it with textures and it'll be displayed in response
of this endpoint. Received response should be directly sent to the client without any modification via game server API.
Actually, this is the [Ely.by](https://ely.by)'s feature called
[Server Skins System](https://ely.by/server-skins-system), but if you have your own source of Mojang's signatures,
then you can pass it with textures and it'll be displayed in response of this endpoint. Received response should be
directly sent to the client without any modification via game server API.
Response example:
@ -160,10 +345,9 @@ You can obtain token by executing `docker-compose run --rm app token`.
#### `POST /api/skins`
> **Warning**: skin uploading via `skin` field is not implemented for now.
Endpoint allows you to create or update skin record for a username.
Endpoint allows you to create or update skin record for a username. To upload skin, you have to send multipart
form data. `form-urlencoded` also supported, but, as you may know, it doesn't support files uploading.
The request body must be encoded as `application/x-www-form-urlencoded`.
**Request params:**
@ -177,8 +361,9 @@ form data. `form-urlencoded` also supported, but, as you may know, it doesn't su
| isSlim | bool | Does skin have slim arms (Alex model). |
| mojangTextures | string | Mojang textures field. It must be a base64 encoded json string. Not required. |
| mojangSignature | string | Signature for Mojang textures, which is required when `mojangTextures` passed. |
| url | string | Actual url of the skin. You have to pass this parameter or `skin`. |
| skin | file | Skin file. You have to pass this parameter or `url`. |
| url | string | Actual url of the skin. |
**Important**: all parameters are always read at least as their default values. So, if you only want to update the username and not pass the skin data it will reset all skin information. If you want to keep the data, you should always pass the full set of parameters.
If successful you'll receive `201` status code. In the case of failure there will be `400` status code and errors list
as json:
@ -215,23 +400,69 @@ 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`
environment variable.
This project uses [`dep`](https://github.com/golang/dep) for dependencies management, so it
[should be installed](https://github.com/golang/dep#installation) too.
Then you must fork this repository. Now follow these steps:
```sh
# Get the source code
go get github.com/elyby/chrly
git clone https://github.com/elyby/chrly.git
# Switch to the project folder
cd $GOPATH/src/github.com/elyby/chrly
# Install dependencies (it can take a while)
dep ensure
cd chrly
# Install dependencies
go mod download
# Add your fork link as a remote
git remote add fork git@github.com:your-username/chrly.git
# Create a new branch for your task
@ -257,14 +488,14 @@ 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
[ico-build]: https://img.shields.io/travis/elyby/chrly.svg?style=flat-square
[ico-lang]: https://img.shields.io/github/go-mod/go-version/elyby/chrly?style=flat-square
[ico-build]: https://img.shields.io/github/actions/workflow/status/elyby/chrly/build.yml?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
[link-go]: https://golang.org
[link-build]: https://travis-ci.org/elyby/chrly
[link-build]: https://github.com/elyby/chrly/actions
[link-coverage]: https://codecov.io/gh/elyby/chrly

View File

@ -3,27 +3,33 @@ package mojang
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
)
var HttpClient = &http.Client{
Timeout: 3 * time.Second,
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: 1024,
},
}
type SignedTexturesResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
once sync.Once
decodedTextures *TexturesProp
decodedErr error
}
func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
if t.decodedTextures == nil {
func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) {
t.once.Do(func() {
var texturesProp string
for _, prop := range t.Props {
if prop.Name == "textures" {
@ -33,14 +39,18 @@ func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
}
if texturesProp == "" {
return nil
return
}
decodedTextures, _ := DecodeTextures(texturesProp)
t.decodedTextures = decodedTextures
}
decodedTextures, err := DecodeTextures(texturesProp)
if err != nil {
t.decodedErr = err
} else {
t.decodedTextures = decodedTextures
}
})
return t.decodedTextures
return t.decodedTextures, t.decodedErr
}
type Property struct {
@ -56,11 +66,17 @@ type ProfileInfo struct {
IsDemo bool `json:"demo,omitempty"`
}
var ApiMojangDotComAddr = "https://api.mojang.com"
var SessionServerMojangComAddr = "https://sessionserver.mojang.com"
// Exchanges usernames array to array of uuids
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
requestBody, _ := json.Marshal(usernames)
request, _ := http.NewRequest("POST", "https://api.mojang.com/profiles/minecraft", bytes.NewBuffer(requestBody))
request, err := http.NewRequest("POST", ApiMojangDotComAddr+"/profiles/minecraft", bytes.NewBuffer(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
@ -85,12 +101,16 @@ 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 := SessionServerMojangComAddr + "/session/minecraft/profile/" + normalizedUuid
if signed {
url += "?unsigned=false"
}
request, _ := http.NewRequest("GET", url, nil)
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
response, err := HttpClient.Do(request)
if err != nil {
@ -125,6 +145,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 +166,7 @@ type EmptyResponse struct {
}
func (*EmptyResponse) Error() string {
return "Empty Response"
return "204: Empty Response"
}
func (*EmptyResponse) IsMojangError() bool {
@ -159,20 +181,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 +217,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

@ -5,6 +5,7 @@ import (
"testing"
"github.com/h2non/gock"
testify "github.com/stretchr/testify/assert"
)
@ -20,7 +21,8 @@ func TestSignedTexturesResponse(t *testing.T) {
},
},
}
textures := obj.DecodeTextures()
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
})
@ -30,7 +32,8 @@ func TestSignedTexturesResponse(t *testing.T) {
Name: "mock",
Props: []*Property{},
}
textures := obj.DecodeTextures()
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Nil(t, textures)
})
}
@ -98,7 +101,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 +146,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 +167,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 +208,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 +233,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 +261,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, "204: Empty Response")
assert.Implements((*ResponseError)(nil), err)
})
@ -261,7 +285,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 +306,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,112 +0,0 @@
package queue
import (
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/tevino/abool"
)
var inMemoryStorageGCPeriod = 10 * time.Second
var inMemoryStoragePersistPeriod = time.Minute + 10*time.Second
var now = time.Now
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
}
type inMemoryTexturesStorage struct {
lock sync.Mutex
data map[string]*inMemoryItem
working *abool.AtomicBool
}
func CreateInMemoryTexturesStorage() *inMemoryTexturesStorage {
storage := &inMemoryTexturesStorage{
data: make(map[string]*inMemoryItem),
}
return storage
}
func (s *inMemoryTexturesStorage) Start() {
if s.working == nil {
s.working = abool.New()
}
if !s.working.IsSet() {
go func() {
time.Sleep(inMemoryStorageGCPeriod)
// 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))
}
}()
}
s.working.Set()
}
func (s *inMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *inMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.Lock()
defer s.lock.Unlock()
item, exists := s.data[uuid]
validRange := getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, &ValueNotFound{}
}
return item.textures, nil
}
func (s *inMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
var timestamp int64
if textures != nil {
decoded := textures.DecodeTextures()
if decoded == nil {
panic("unable to decode textures")
}
timestamp = decoded.Timestamp
} else {
timestamp = unixNanoToUnixMicro(now().UnixNano())
}
s.lock.Lock()
defer s.lock.Unlock()
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: timestamp,
}
}
func (s *inMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
maxTime := getMinimalNotExpiredTimestamp()
for uuid, value := range s.data {
if maxTime > value.timestamp {
delete(s.data, uuid)
}
}
}
func getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(inMemoryStoragePersistPeriod * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
}

View File

@ -1,200 +0,0 @@
package queue
import (
"time"
"github.com/elyby/chrly/api/mojang"
testify "github.com/stretchr/testify/assert"
"testing"
)
var texturesWithSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{
Skin: &mojang.SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
},
},
}),
},
},
}
var texturesWithoutSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
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()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
})
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
now = func() time.Time {
return time.Now().Add(time.Minute * 2)
}
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
now = time.Now
})
}
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.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("override already existed textures for uuid", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(texturesWithoutSkin, result)
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Nil(err)
})
t.Run("should panic if textures prop is not decoded", func(t *testing.T) {
assert := testify.New(t)
toStore := &mojang.SignedTexturesResponse{
Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
Name: "mock",
Props: []*mojang.Property{},
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
inMemoryStorageGCPeriod = 10 * time.Millisecond
inMemoryStoragePersistPeriod = 10 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let it start first iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Error(textures1Err)
assert.Error(textures2Err)
storage.Stop()
}

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,215 +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(10)
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
}
if _, ok := err.(*mojang.BadRequestError); ok {
ctx.Logger.Warning(":name: Got 400 Bad Request :err", wd.NameParam(threadName), wd.ErrParam(err))
return
}
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,522 +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) TestReceiveTexturesForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Times(12)
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Times(12)
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(10)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_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).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Times(12)
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Times(12)
suite.Storage.On("GetUuid", mock.Anything).Times(12).Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", mock.Anything, "").Times(12).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:10]).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return([]*mojang.ProfileInfo{}, nil)
channels := make([]chan *mojang.SignedTexturesResponse, 12)
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 400 Bad Request :err", mock.Anything, mock.Anything).Once()
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 400 Bad Request :err", mock.Anything, mock.Anything).Once()
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/defval/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)
}

56
cmd/root_profiling.go Normal file
View File

@ -0,0 +1,56 @@
//go:build profiling
// +build profiling
package cmd
import (
"log"
"os"
"runtime/pprof"
"github.com/spf13/cobra"
)
func init() {
var profilePath string
RootCmd.PersistentFlags().StringVar(&profilePath, "cpuprofile", "", "enables pprof profiling and sets its output path")
pprofEnabled := false
originalPersistentPreRunE := RootCmd.PersistentPreRunE
RootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
if profilePath == "" {
return nil
}
f, err := os.Create(profilePath)
if err != nil {
return err
}
log.Println("enabling profiling")
err = pprof.StartCPUProfile(f)
if err != nil {
return err
}
pprofEnabled = true
if originalPersistentPreRunE != nil {
return originalPersistentPreRunE(cmd, args)
}
return nil
}
originalPersistentPostRun := RootCmd.PersistentPreRun
RootCmd.PersistentPostRun = func(cmd *cobra.Command, args []string) {
if pprofEnabled {
log.Println("shutting down profiling")
pprof.StopCPUProfile()
}
if originalPersistentPostRun != nil {
originalPersistentPostRun(cmd, args)
}
}
}

View File

@ -1,89 +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
}
texturesStorage := queue.CreateInMemoryTexturesStorage()
texturesStorage.Start()
mojangTexturesQueue := &queue.JobsQueue{
Logger: logger,
Storage: &queue.SplittedStorage{
UuidsStorage: mojangUuidsRepository,
TexturesStorage: texturesStorage,
},
}
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,307 +0,0 @@
package db
import (
"bytes"
"compress/zlib"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/mediocregopher/radix.v2/pool"
"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"
)
type RedisFactory struct {
Host string
Port int
PoolSize int
pool *pool.Pool
}
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()
if err != nil {
return nil, err
}
return &redisDb{p}, nil
}
func (f *RedisFactory) getPool() (*pool.Pool, error) {
if f.pool == nil {
if f.Host == "" {
return nil, &ParamRequired{"host"}
}
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 {
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) {
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
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
}
result, err := zlibDecode(encodedResult)
if err != nil {
return nil, err
}
var skin *model.Skin
err = json.Unmarshal(result, &skin)
if err != nil {
return nil, err
}
skin.OldUsername = skin.Username
return skin, nil
}
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"}
}
username, _ := response.Str()
return findByUsername(username, conn)
}
func removeByUserId(id int, conn util.Cmder) error {
record, err := findByUserId(id, conn)
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
}
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
conn.Cmd("EXEC")
return nil
}
func save(skin *model.Skin, conn util.Cmder) error {
conn.Cmd("MULTI")
// If user has changed username, then we must delete his old username record
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
conn.Cmd("DEL", buildUsernameKey(skin.OldUsername))
}
// If this is a new record or if the user has changed username, we set the value in the hash table
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username)
}
str, _ := json.Marshal(skin)
conn.Cmd("SET", buildUsernameKey(skin.Username), zlibEncode(str))
conn.Cmd("EXEC")
skin.OldUsername = skin.Username
return nil
}
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{}
}
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{}
}
return parts[0], nil
}
func storeMojangUuid(username string, uuid string, conn util.Cmder) error {
value := uuid + ":" + strconv.FormatInt(time.Now().Unix(), 10)
res := conn.Cmd("HSET", mojangUsernameToUuidKey, strings.ToLower(username), value)
if res.IsType(redis.Err) {
return res.Err
}
return nil
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
_, _ = writer.Write(str)
_ = writer.Close()
return buff.Bytes()
}
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, readError := zlib.NewReader(buff)
if readError != nil {
return nil, readError
}
resultBuffer := new(bytes.Buffer)
_, _ = io.Copy(resultBuffer, reader)
reader.Close()
return resultBuffer.Bytes(), nil
}

315
db/redis/redis.go Normal file
View File

@ -0,0 +1,315 @@
package redis
import (
"bytes"
"compress/zlib"
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/mediocregopher/radix/v4"
"github.com/elyby/chrly/model"
)
var now = time.Now
func New(ctx context.Context, addr string, poolSize int) (*Redis, error) {
client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr)
if err != nil {
return nil, err
}
return &Redis{
client: client,
context: ctx,
}, nil
}
const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username"
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
type Redis struct {
client radix.Client
context context.Context
}
func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) {
var skin *model.Skin
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
skin, err = findByUsername(ctx, conn, username)
return err
}))
return skin, err
}
func findByUsername(ctx context.Context, conn radix.Conn, username string) (*model.Skin, error) {
redisKey := buildUsernameKey(username)
var encodedResult []byte
err := conn.Do(ctx, radix.Cmd(&encodedResult, "GET", redisKey))
if err != nil {
return nil, err
}
if len(encodedResult) == 0 {
return nil, nil
}
result, err := zlibDecode(encodedResult)
if err != nil {
return nil, err
}
var skin *model.Skin
err = json.Unmarshal(result, &skin)
if err != nil {
return nil, err
}
// Some old data causing issues in the production.
// TODO: remove after investigation will be finished
if skin.Uuid == "" {
return nil, nil
}
skin.OldUsername = skin.Username
return skin, nil
}
func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
var skin *model.Skin
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
skin, err = findByUserId(ctx, conn, id)
return err
}))
return skin, err
}
func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, error) {
var username string
err := conn.Do(ctx, radix.FlatCmd(&username, "HGET", accountIdToUsernameKey, id))
if err != nil {
return nil, err
}
if username == "" {
return nil, nil
}
return findByUsername(ctx, conn, username)
}
func (db *Redis) SaveSkin(skin *model.Skin) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return save(ctx, conn, skin)
}))
}
func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error {
err := conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
// If user has changed username, then we must delete his old username record
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(skin.OldUsername)))
if err != nil {
return err
}
}
// If this is a new record or if the user has changed username, we set the value in the hash table
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", accountIdToUsernameKey, skin.UserId, skin.Username))
}
str, _ := json.Marshal(skin)
err = conn.Do(ctx, radix.FlatCmd(nil, "SET", buildUsernameKey(skin.Username), zlibEncode(str)))
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "EXEC"))
if err != nil {
return err
}
skin.OldUsername = skin.Username
return nil
}
func (db *Redis) RemoveSkinByUserId(id int) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return removeByUserId(ctx, conn, id)
}))
}
func removeByUserId(ctx context.Context, conn radix.Conn, id int) error {
record, err := findByUserId(ctx, conn, id)
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, id))
if err != nil {
return err
}
if record != nil {
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username)))
if err != nil {
return err
}
}
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
}
func (db *Redis) RemoveSkinByUsername(username string) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return removeByUsername(ctx, conn, username)
}))
}
func removeByUsername(ctx context.Context, conn radix.Conn, username string) error {
record, err := findByUsername(ctx, conn, username)
if err != nil {
return err
}
if record == nil {
return nil
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username)))
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, record.UserId))
if err != nil {
return err
}
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
}
func (db *Redis) GetUuid(username string) (string, bool, error) {
var uuid string
var found bool
err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
uuid, found, err = findMojangUuidByUsername(ctx, conn, username)
return err
}))
return uuid, found, err
}
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, bool, error) {
key := strings.ToLower(username)
var result string
err := conn.Do(ctx, radix.Cmd(&result, "HGET", mojangUsernameToUuidKey, key))
if err != nil {
return "", false, err
}
if result == "" {
return "", false, nil
}
parts := strings.Split(result, ":")
// https://github.com/elyby/chrly/issues/28
if len(parts) < 2 {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
if err != nil {
return "", false, err
}
return "", false, fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result)
}
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key))
if err != nil {
return "", false, err
}
return "", false, nil
}
return parts[0], true, nil
}
func (db *Redis) StoreUuid(username string, uuid string) error {
return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return storeMojangUuid(ctx, conn, username, uuid)
}))
}
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
value := uuid + ":" + strconv.FormatInt(now().Unix(), 10)
err := conn.Do(ctx, radix.Cmd(nil, "HSET", mojangUsernameToUuidKey, strings.ToLower(username), value))
if err != nil {
return err
}
return nil
}
func (db *Redis) Ping() error {
return db.client.Do(db.context, radix.Cmd(nil, "PING"))
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
_, _ = writer.Write(str)
_ = writer.Close()
return buff.Bytes()
}
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, err := zlib.NewReader(buff)
if err != nil {
return nil, err
}
resultBuffer := new(bytes.Buffer)
_, _ = io.Copy(resultBuffer, reader)
_ = reader.Close()
return resultBuffer.Bytes(), nil
}

View File

@ -0,0 +1,425 @@
//go:build redis
package redis
import (
"context"
"fmt"
"os"
"strconv"
"testing"
"time"
"github.com/mediocregopher/radix/v4"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
)
var redisAddr string
func init() {
host := "localhost"
port := 6379
if os.Getenv("STORAGE_REDIS_HOST") != "" {
host = os.Getenv("STORAGE_REDIS_HOST")
}
if os.Getenv("STORAGE_REDIS_PORT") != "" {
port, _ = strconv.Atoi(os.Getenv("STORAGE_REDIS_PORT"))
}
redisAddr = fmt.Sprintf("%s:%d", host, port)
}
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(context.Background(), redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
})
t.Run("should return error", func(t *testing.T) {
conn, err := New(context.Background(), "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{}) string
}
func (suite *redisTestSuite) SetupSuite() {
ctx := context.Background()
conn, err := New(ctx, redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
suite.Redis = conn
suite.cmd = func(cmd string, args ...interface{}) string {
var result string
err := suite.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
if err != nil {
panic(err)
}
return result
}
}
func (suite *redisTestSuite) SetupTest() {
// Cleanup database before 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 = string([]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().NotEmpty(usernameResp)
suite.Require().Equal(skinRecord, usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Equal("Mock", idResp)
})
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().NotEmpty(usernameResp)
suite.Require().Equal(string([]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,
}), usernameResp)
oldUsernameResp := suite.cmd("GET", "username:mock")
suite.Require().Empty(oldUsernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal("NewMock", idResp)
})
}
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().Empty(usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Empty(idResp)
})
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().Empty(idResp)
})
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().Empty(usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().Empty(idResp)
})
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().Empty(usernameResp)
})
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, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().True(found)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("exists record with empty uuid value", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf(":%d", time.Now().Unix()),
)
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().True(found)
suite.Require().Empty("", uuid)
})
suite.RunSubTest("not exists record", func() {
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().False(found)
suite.Require().Empty(uuid)
})
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, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().False(found)
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Empty(resp, "should cleanup expired records")
})
suite.RunSubTest("exists, but corrupted record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
"corrupted value",
)
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().False(found)
suite.Require().Error(err, "Got unexpected response from the mojangUsernameToUuid hash: \"corrupted value\"")
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Empty(resp, "should cleanup expired records")
})
}
func (suite *redisTestSuite) TestStoreUuid() {
suite.RunSubTest("store uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Equal(resp, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
})
suite.RunSubTest("store empty uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Equal(resp, ":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/defval/di"
"github.com/spf13/viper"
)
var config = di.Options(
di.Provide(newConfig),
)
func newConfig() *viper.Viper {
return viper.GetViper()
}

72
di/db.go Normal file
View File

@ -0,0 +1,72 @@
package di
import (
"context"
"fmt"
"path"
"github.com/defval/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.poolSize", 10)
conn, err := redis.New(
context.Background(),
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 {
return mojangtextures.NewInMemoryTexturesStorage()
}

21
di/di.go Normal file
View File

@ -0,0 +1,21 @@
package di
import "github.com/defval/di"
func New() (*di.Container, error) {
container, err := di.New(
config,
dispatcher,
logger,
db,
mojangTextures,
handlers,
server,
signer,
)
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/defval/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/defval/di/issues/10#issuecomment-615869852
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
(&eventsubscribers.StatsReporter{StatsReporter: statsReporter}).ConfigureWithDispatcher(dispatcher)
}

163
di/handlers.go Normal file
View File

@ -0,0 +1,163 @@
package di
import (
"errors"
"net/http"
"strings"
"github.com/defval/di"
"github.com/etherlabsio/healthcheck/v2"
"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)
}
err := container.Invoke(enableReporters)
if err != nil && !errors.Is(err, di.ErrTypeNotExists) {
return nil, err
}
// 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 has, _ := container.Has(&healthCheckers); has {
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,
texturesSigner TexturesSigner,
) (*mux.Router, error) {
config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
app, err := NewSkinsystem(
emitter,
skinsRepository,
capesRepository,
mojangTexturesProvider,
texturesSigner,
config.GetString("textures.extra_param_name"),
config.GetString("textures.extra_param_value"),
)
if err != nil {
return nil, err
}
return app.Handler(), nil
}
func newApiHandler(skinsRepository SkinsRepository) *mux.Router {
return (&Api{
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
}

104
di/logger.go Normal file
View File

@ -0,0 +1,104 @@
package di
import (
"os"
"github.com/defval/di"
"github.com/getsentry/raven-go"
"github.com/mono83/slf"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
"github.com/mono83/slf/recievers/statsd"
"github.com/mono83/slf/recievers/writer"
"github.com/mono83/slf/wd"
"github.com/spf13/viper"
"github.com/elyby/chrly/eventsubscribers"
"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
}
func enableReporters(reporter slf.StatsReporter, factories []eventsubscribers.Reporter) {
for _, factory := range factories {
factory.Enable(reporter)
}
}

235
di/mojang_textures.go Normal file
View File

@ -0,0 +1,235 @@
package di
import (
"context"
"fmt"
"net/url"
"time"
"github.com/defval/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/api/mojang"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var mojangTextures = di.Options(
di.Invoke(interceptMojangApiUrls),
di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangTexturesBatchUUIDsProviderStrategyFactory),
di.Provide(newMojangTexturesBatchUUIDsProviderDelayedStrategy),
di.Provide(newMojangTexturesBatchUUIDsProviderFullBusStrategy),
di.Provide(newMojangTexturesRemoteUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider),
di.Provide(newMojangTexturesStorageFactory),
)
func interceptMojangApiUrls(config *viper.Viper) error {
apiUrl := config.GetString("mojang.api_base_url")
if apiUrl != "" {
u, err := url.ParseRequestURI(apiUrl)
if err != nil {
return err
}
mojang.ApiMojangDotComAddr = u.String()
}
sessionServerUrl := config.GetString("mojang.session_server_base_url")
if sessionServerUrl != "" {
u, err := url.ParseRequestURI(apiUrl)
if err != nil {
return err
}
mojang.SessionServerMojangComAddr = u.String()
}
return nil
}
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,
strategy mojangtextures.BatchUuidsProviderStrategy,
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
}
return mojangtextures.NewBatchUuidsProvider(context.Background(), strategy, emitter), nil
}
func newMojangTexturesBatchUUIDsProviderStrategyFactory(
container *di.Container,
config *viper.Viper,
) (mojangtextures.BatchUuidsProviderStrategy, error) {
config.SetDefault("queue.strategy", "periodic")
strategyName := config.GetString("queue.strategy")
switch strategyName {
case "periodic":
var strategy *mojangtextures.PeriodicStrategy
err := container.Resolve(&strategy)
if err != nil {
return nil, err
}
return strategy, nil
case "full-bus":
var strategy *mojangtextures.FullBusStrategy
err := container.Resolve(&strategy)
if err != nil {
return nil, err
}
return strategy, nil
default:
return nil, fmt.Errorf("unknown queue strategy \"%s\"", strategyName)
}
}
func newMojangTexturesBatchUUIDsProviderDelayedStrategy(config *viper.Viper) *mojangtextures.PeriodicStrategy {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return mojangtextures.NewPeriodicStrategy(
config.GetDuration("queue.loop_delay"),
config.GetInt("queue.batch_size"),
)
}
func newMojangTexturesBatchUUIDsProviderFullBusStrategy(config *viper.Viper) *mojangtextures.FullBusStrategy {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return mojangtextures.NewFullBusStrategy(
config.GetDuration("queue.loop_delay"),
config.GetInt("queue.batch_size"),
)
}
func newMojangTexturesRemoteUUIDsProvider(
container *di.Container,
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)
}
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_api_textures_provider_cool_down_duration", time.Minute+10*time.Second)
return &namedHealthChecker{
Name: "mojang-api-textures-provider-response-checker",
Checker: es.MojangApiTexturesProviderResponseChecker(
emitter,
config.GetDuration("healthcheck.mojang_api_textures_provider_cool_down_duration"),
),
}
}); err != nil {
return nil, 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,
}
}

79
di/server.go Normal file
View File

@ -0,0 +1,79 @@
package di
import (
"errors"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/defval/di"
"github.com/getsentry/raven-go"
"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)
var handler http.Handler
if params.Sentry != nil {
// raven.Recoverer uses DefaultClient and nothing can be done about it
// To avoid code duplication, if the Sentry service is successfully initiated,
// it will also replace DefaultClient, so raven.Recoverer will work with the instance
// created in the application constructor
handler = raven.Recoverer(params.Handler)
} else {
// Raven's Recoverer is prints the stacktrace and sets the corresponding status itself.
// But there is no magic and if you don't define a panic handler, Mux will just reset the connection
handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
debug.PrintStack() // TODO: colorize output
request.WriteHeader(http.StatusInternalServerError)
}
}()
params.Handler.ServeHTTP(request, response)
})
}
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))
server := &http.Server{
Addr: address,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
return server
}

48
di/signer.go Normal file
View File

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

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,111 @@
package eventsubscribers
import (
"context"
"errors"
"sync"
"time"
"github.com/etherlabsio/healthcheck/v2"
"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 {
errHolder := &expiringErrHolder{D: resetDuration}
dispatcher.Subscribe(
"mojang_textures:batch_uuids_provider:result",
func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
errHolder.Set(err)
},
)
return func(ctx context.Context) error {
return errHolder.Get()
}
}
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")
}
}
func MojangApiTexturesProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
errHolder := &expiringErrHolder{D: resetDuration}
dispatcher.Subscribe(
"mojang_textures:mojang_api_textures_provider:after_request",
func(uuid string, profile *mojang.SignedTexturesResponse, err error) {
errHolder.Set(err)
},
)
return func(ctx context.Context) error {
return errHolder.Get()
}
}
type expiringErrHolder struct {
D time.Duration
err error
l sync.Mutex
t *time.Timer
}
func (h *expiringErrHolder) Get() error {
h.l.Lock()
defer h.l.Unlock()
return h.err
}
func (h *expiringErrHolder) Set(err error) {
h.l.Lock()
defer h.l.Unlock()
if h.t != nil {
h.t.Stop()
h.t = nil
}
h.err = err
if err != nil {
h.t = time.AfterFunc(h.D, func() {
h.Set(nil)
})
}
}

View File

@ -0,0 +1,148 @@
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, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
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())
}
})
}
func TestMojangApiTexturesProviderResponseChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
d.Emit("mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
&mojang.SignedTexturesResponse{},
nil,
)
assert.Nil(t, checker(context.Background()))
})
t.Run("when error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 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 := MojangApiTexturesProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
}

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,190 @@
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
}
type Reporter interface {
Enable(reporter slf.StatsReporter)
}
type ReporterFunc func(reporter slf.StatsReporter)
func (f ReporterFunc) Enable(reporter slf.StatsReporter) {
f(reporter)
}
// TODO: rework all reporters in the same style as it was there: https://github.com/elyby/chrly/blob/1543e98b/di/db.go#L48-L52
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, found bool, err error) {
if err != nil || !found {
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))
if len(usernames) != 0 {
s.startTimeRecording("batch_uuids_provider_round_time_" + strings.Join(usernames, "|"))
}
})
d.Subscribe("mojang_textures:batch_uuids_provider:result", func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
s.finalizeTimeRecording("batch_uuids_provider_round_time_"+strings.Join(usernames, "|"), "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 strings.HasPrefix(p, "/profile/") {
key = "profiles.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,402 @@
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("GET", "http://localhost/profile/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "profiles.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", "", false, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", false, nil},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", true, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, 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},
{"mojang_textures:batch_uuids_provider:result", []string{"username1", "username2"}, []*mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:round", []string{}, 0},
// This event will be not emitted, but we emit it to ensure, that RecordTimer will not be called
{"mojang_textures:batch_uuids_provider:result", []string{}, []*mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(0)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)},
// Should not call RecordTimer
},
},
}
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
}

55
go.mod Normal file
View File

@ -0,0 +1,55 @@
module github.com/elyby/chrly
go 1.21
replace github.com/asaskevich/EventBus v0.0.0-20200330115301-33b3bc6a7ddc => github.com/erickskrauch/EventBus v0.0.0-20200330115301-33b3bc6a7ddc
// Main dependencies
require (
github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2
github.com/asaskevich/EventBus v0.0.0-20200330115301-33b3bc6a7ddc
github.com/defval/di v1.12.0
github.com/etherlabsio/healthcheck/v2 v2.0.0
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea
github.com/gorilla/mux v1.8.1
github.com/mediocregopher/radix/v4 v4.1.4
github.com/mono83/slf v0.0.0-20170919161409-79153e9636db
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.1
github.com/thedevsaddam/govalidator v1.9.10
)
// Dev dependencies
require (
github.com/h2non/gock v1.2.0
github.com/stretchr/testify v1.8.4
)
require (
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mono83/udpwriter v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tilinna/clock v1.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

106
go.sum Normal file
View File

@ -0,0 +1,106 @@
github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2 h1:koK7z0nSsRiRiBWwa+E714Puh+DO+ZRdIyAXiXzL+lg=
github.com/SermoDigital/jose v0.9.2-0.20161205224733-f6df55f235c2/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/defval/di v1.12.0 h1:xXm7BMX2+Nr0Yyu55DeJl/rmfCA7CQX89f4AGE0zA6U=
github.com/defval/di v1.12.0/go.mod h1:PhVbOxQOvU7oawTOJXXTvqOJp1Dvsjs5PuzMw9gGl0I=
github.com/erickskrauch/EventBus v0.0.0-20200330115301-33b3bc6a7ddc h1:kz3f5uMA1LxfRvJjZmMYG7Zu2rddTfJy6QZofz2YoGQ=
github.com/erickskrauch/EventBus v0.0.0-20200330115301-33b3bc6a7ddc/go.mod h1:RHSo3YFV/SbOGyFR36RKWaXPy3g9nKAmn6ebNLpbco4=
github.com/etherlabsio/healthcheck/v2 v2.0.0 h1:oKq8cbpwM/yNGPXf2Sff6MIjVUjx/pGYFydWzeK2MpA=
github.com/etherlabsio/healthcheck/v2 v2.0.0/go.mod h1:huNVOjKzu6FI1eaO1CGD3ZjhrmPWf5Obu/pzpI6/wog=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea h1:t6e33/eet/VyiHHHKs0cBytUISUWQ/hmQwOlqtFoGEo=
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts=
github.com/mediocregopher/radix/v4 v4.1.4/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mono83/slf v0.0.0-20170919161409-79153e9636db h1:tlz4fTklh5mttoq5M+0yEc5Lap8W/02A2HCXCJn5iz0=
github.com/mono83/slf v0.0.0-20170919161409-79153e9636db/go.mod h1:MfF+zNMZz+5IGY9h8jpFaGLyGoJ2ZPri2FmUVftBoUU=
github.com/mono83/udpwriter v1.0.2 h1:JiQ/N646oZoJA1G0FOMvn2teMt6SdL1KwNH2mszOlQs=
github.com/mono83/udpwriter v1.0.2/go.mod h1:mTDiyLtA0tXoxckkV9T4NUkJTgSQIuO8pAUKx/dSRkQ=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM=
github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU=
github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90=
github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI=
github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -1,37 +1,24 @@
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
// noinspection GoSnakeCaseUsage
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
var regexUuidAny = regexp.MustCompile(UUID_ANY)
func init() {
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
if message == "" {
message = "Skin uploading is temporary unavailable"
}
return errors.New(message)
})
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
@ -47,11 +34,22 @@ func init() {
})
}
func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.post.request", 1)
type Api struct {
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,11 +57,16 @@ 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))
apiServerError(resp)
return
panic(err)
}
if record == nil {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
@ -78,121 +81,115 @@ 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))
apiServerError(resp)
return
panic(err)
}
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)
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
cfg.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested username")
panic(err)
}
if skin == nil {
apiNotFound(resp, "Cannot find record for the requested identifier")
return
}
cfg.deleteSkin(skin, 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)
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil {
cfg.Logger.Error("Cannot delete skin by error: :err", wd.ErrParam(err))
apiServerError(resp)
return
panic(err)
}
cfg.Logger.IncCounter("api.skins.delete.success", 1)
resp.WriteHeader(http.StatusNoContent)
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
_ = request.ParseMultipartForm(maxMultipartMemory)
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"is1_8": {"bool"},
"isSlim": {"bool"},
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 {
_ = request.ParseForm()
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric"},
"url": {},
"is1_8": {"bool"},
"isSlim": {"bool"},
"mojangTextures": {},
"mojangSignature": {},
}
shouldAppendSkinRequiredError := false
url := request.Form.Get("url")
_, _, skinErr := request.FormFile("skin")
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
shouldAppendSkinRequiredError = true
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
if url == "" {
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:0,0")
} else {
validationRules["url"] = append(validationRules["url"], "url")
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:1,")
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = []string{"required"}
validationRules["mojangSignature"] = append(validationRules["mojangSignature"], "required")
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
FormSize: maxMultipartMemory,
})
validationResults := validator.Validate()
if shouldAppendSkinRequiredError {
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
}
if len(validationResults) != 0 {
return validationResults
@ -200,60 +197,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,224 +3,205 @@ package http
import (
"bytes"
"encoding/base64"
"errors"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"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
}
form := url.Values{
/********************
* Setup test suite *
********************/
func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.App = &Api{
SkinsRepo: suite.SkinsRepository,
}
}
func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.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)
PanicErr string
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()
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 textures data to empty",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"0"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {""},
"mojangTextures": {""},
"mojangSignature": {""},
}.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(0, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("", model.Url)
suite.Equal("", model.MojangTextures)
suite.Equal("", model.MojangSignature)
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, _ := io.ReadAll(response.Body)
suite.Equal("", string(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 +209,93 @@ 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": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, errors.New("can't find skin by user id"))
},
PanicErr: "can't find skin by user id",
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"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)
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures"))
},
PanicErr: "can't save textures",
},
}
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)
})
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
t.Run("Get errors about required fields", func(t *testing.T) {
assert := testify.New(t)
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",
@ -290,7 +305,7 @@ func TestConfig_PostSkin(t *testing.T) {
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
"The skinId field must be numeric value between 0 and 0"
],
"username": [
"The username field is required"
@ -299,193 +314,95 @@ func TestConfig_PostSkin(t *testing.T) {
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
}
}`, string(response))
})
t.Run("Perform request without authorization", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://chrly/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
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)
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))
}`, string(body))
})
}
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,137 @@
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))
close(done)
}
}()
server := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: cfg.CreateHandler(),
}
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)
}()
go server.Serve(listener)
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, syscall.SIGTERM, 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)
}

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,17 +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",
})
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusNotFound)
response.Write(data)
}

View File

@ -1,27 +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"
}`, 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: "chrly",
Value: "how do you tame a horse in Minecraft?",
})
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": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}`, 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": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}`, 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,
}
}

405
http/skinsystem.go Normal file
View File

@ -0,0 +1,405 @@
package http
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/utils"
)
var timeNow = time.Now
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 TexturesSigner interface {
SignTextures(textures string) (string, error)
GetPublicKey() (*rsa.PublicKey, error)
}
type Skinsystem struct {
Emitter
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
TexturesSigner TexturesSigner
TexturesExtraParamName string
TexturesExtraParamValue string
texturesExtraParamSignature string
}
func NewSkinsystem(
emitter Emitter,
skinsRepo SkinsRepository,
capesRepo CapesRepository,
mojangTexturesProvider MojangTexturesProvider,
texturesSigner TexturesSigner,
texturesExtraParamName string,
texturesExtraParamValue string,
) (*Skinsystem, error) {
texturesExtraParamSignature, err := texturesSigner.SignTextures(texturesExtraParamValue)
if err != nil {
return nil, fmt.Errorf("unable to generate signature for textures extra param: %w", err)
}
return &Skinsystem{
Emitter: emitter,
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
MojangTexturesProvider: mojangTexturesProvider,
TexturesSigner: texturesSigner,
TexturesExtraParamName: texturesExtraParamName,
TexturesExtraParamValue: texturesExtraParamValue,
texturesExtraParamSignature: texturesExtraParamSignature,
}, nil
}
type profile struct {
Id string
Username string
Textures *mojang.TexturesResponse
CapeFile io.Reader
MojangTextures string
MojangSignature string
}
func (ctx *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
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)
router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet)
// Legacy
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
// Utils
router.HandleFunc("/signature-verification-key.der", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
router.HandleFunc("/signature-verification-key.pem", ctx.signatureVerificationKeyHandler).Methods(http.MethodGet)
return router
}
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, profile.Textures.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
ctx.skinHandler(response, request)
}
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil || profile.Textures == nil || (profile.CapeFile == nil && profile.Textures.Cape == nil) {
response.WriteHeader(http.StatusNotFound)
return
}
if profile.CapeFile == nil {
http.Redirect(response, request, profile.Textures.Cape.Url, 301)
} else {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, profile.CapeFile)
}
}
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
ctx.capeHandler(response, request)
}
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil || profile.Textures == nil || (profile.Textures.Skin == nil && profile.Textures.Cape == nil) {
response.WriteHeader(http.StatusNoContent)
return
}
responseData, _ := json.Marshal(profile.Textures)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "")
if err != nil {
panic(err)
}
if profile == nil || profile.MojangTextures == "" {
response.WriteHeader(http.StatusNoContent)
return
}
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
Name: profile.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: profile.MojangSignature,
Value: profile.MojangTextures,
},
{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
},
},
}
responseJson, _ := json.Marshal(profileResponse)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) {
profile, err := ctx.getProfile(request, true)
if err != nil {
panic(err)
}
if profile == nil {
forceResponseWithUuid := request.URL.Query().Get("onUnknownProfileRespondWithUuid")
if forceResponseWithUuid == "" {
response.WriteHeader(http.StatusNoContent)
return
}
profile = createEmptyProfile()
profile.Id = formatUuid(forceResponseWithUuid)
profile.Username = parseUsername(mux.Vars(request)["username"])
}
texturesPropContent := &mojang.TexturesProp{
Timestamp: utils.UnixMillisecond(timeNow()),
ProfileID: profile.Id,
ProfileName: profile.Username,
Textures: profile.Textures,
}
texturesPropValueJson, _ := json.Marshal(texturesPropContent)
texturesPropEncodedValue := base64.StdEncoding.EncodeToString(texturesPropValueJson)
texturesProp := &mojang.Property{
Name: "textures",
Value: texturesPropEncodedValue,
}
customProp := &mojang.Property{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
}
if request.URL.Query().Get("unsigned") == "false" {
customProp.Signature = ctx.texturesExtraParamSignature
texturesSignature, err := ctx.TexturesSigner.SignTextures(texturesProp.Value)
if err != nil {
panic(err)
}
texturesProp.Signature = texturesSignature
}
profileResponse := &mojang.SignedTexturesResponse{
Id: profile.Id,
Name: profile.Username,
Props: []*mojang.Property{
texturesProp,
customProp,
},
}
responseJson, _ := json.Marshal(profileResponse)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWriter, request *http.Request) {
publicKey, err := ctx.TexturesSigner.GetPublicKey()
if err != nil {
panic(err)
}
asn1Bytes, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
panic(err)
}
if strings.HasSuffix(request.URL.Path, ".pem") {
publicKeyBlock := pem.Block{
Type: "PUBLIC KEY",
Bytes: asn1Bytes,
}
publicKeyPemBytes := pem.EncodeToMemory(&publicKeyBlock)
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.pem\"")
_, _ = response.Write(publicKeyPemBytes)
} else {
response.Header().Set("Content-Type", "application/octet-stream")
response.Header().Set("Content-Disposition", "attachment; filename=\"yggdrasil_session_pubkey.der\"")
_, _ = response.Write(asn1Bytes)
}
}
// TODO: in v5 should be extracted into some ProfileProvider interface,
//
// which will encapsulate all logics, declared in this method
func (ctx *Skinsystem) getProfile(request *http.Request, proxy bool) (*profile, error) {
username := parseUsername(mux.Vars(request)["username"])
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err != nil {
return nil, err
}
profile := createEmptyProfile()
if skin != nil {
profile.Id = formatUuid(skin.Uuid)
profile.Username = skin.Username
}
if skin != nil && skin.Url != "" {
profile.Textures.Skin = &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
profile.Textures.Skin.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
cape, _ := ctx.CapesRepo.FindCapeByUsername(username)
if cape != nil {
profile.CapeFile = cape.File
profile.Textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
profile.MojangTextures = skin.MojangTextures
profile.MojangSignature = skin.MojangSignature
} else if proxy {
mojangProfile, err := ctx.MojangTexturesProvider.GetForUsername(username)
// If we at least know something about a user,
// than we can ignore an error and return profile without textures
if err != nil && profile.Id != "" {
return profile, nil
}
if err != nil || mojangProfile == nil {
return nil, err
}
decodedTextures, err := mojangProfile.DecodeTextures()
if err != nil {
return nil, err
}
// There might be no textures property
if decodedTextures != nil {
profile.Textures = decodedTextures.Textures
}
var texturesProp *mojang.Property
for _, prop := range mojangProfile.Props {
if prop.Name == "textures" {
texturesProp = prop
break
}
}
if texturesProp != nil {
profile.MojangTextures = texturesProp.Value
profile.MojangSignature = texturesProp.Signature
}
// If user id is unknown at this point, then use values from Mojang profile
if profile.Id == "" {
profile.Id = mojangProfile.Id
profile.Username = mojangProfile.Name
}
} else if profile.Id != "" {
return profile, nil
} else {
return nil, nil
}
return profile, nil
}
func createEmptyProfile() *profile {
return &profile{
Textures: &mojang.TexturesResponse{}, // Field must be initialized to avoid "null" after json encoding
}
}
func formatUuid(uuid string) string {
return strings.Replace(uuid, "-", "", -1)
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}

1286
http/skinsystem_test.go Normal file

File diff suppressed because it is too large Load Diff

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

@ -4,7 +4,7 @@ type Skin struct {
UserId int `json:"userId"`
Uuid string `json:"uuid"`
Username string `json:"username"`
SkinId int `json:"skinId"`
SkinId int `json:"skinId"` // deprecated
Url string `json:"url"`
Is1_8 bool `json:"is1_8"`
IsSlim bool `json:"isSlim"`

View File

@ -0,0 +1,249 @@
package mojangtextures
import (
"context"
"strings"
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
)
type jobResult struct {
Profile *mojang.ProfileInfo
Error error
}
type job struct {
Username string
RespondChan chan *jobResult
}
type jobsQueue struct {
lock sync.Mutex
items []*job
}
func newJobsQueue() *jobsQueue {
return &jobsQueue{
items: []*job{},
}
}
func (s *jobsQueue) Enqueue(job *job) int {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, job)
return len(s.items)
}
func (s *jobsQueue) Dequeue(n int) ([]*job, int) {
s.lock.Lock()
defer s.lock.Unlock()
l := len(s.items)
if n > l {
n = l
}
items := s.items[0:n]
s.items = s.items[n:l]
return items, l - n
}
var usernamesToUuids = mojang.UsernamesToUuids
type JobsIteration struct {
Jobs []*job
Queue int
c chan struct{}
}
func (j *JobsIteration) Done() {
if j.c != nil {
close(j.c)
}
}
type BatchUuidsProviderStrategy interface {
Queue(job *job)
GetJobs(abort context.Context) <-chan *JobsIteration
}
type PeriodicStrategy struct {
Delay time.Duration
Batch int
queue *jobsQueue
done chan struct{}
}
func NewPeriodicStrategy(delay time.Duration, batch int) *PeriodicStrategy {
return &PeriodicStrategy{
Delay: delay,
Batch: batch,
queue: newJobsQueue(),
}
}
func (ctx *PeriodicStrategy) Queue(job *job) {
ctx.queue.Enqueue(job)
}
func (ctx *PeriodicStrategy) GetJobs(abort context.Context) <-chan *JobsIteration {
ch := make(chan *JobsIteration)
go func() {
for {
select {
case <-abort.Done():
close(ch)
return
case <-time.After(ctx.Delay):
jobs, queueLen := ctx.queue.Dequeue(ctx.Batch)
jobDoneChan := make(chan struct{})
ch <- &JobsIteration{jobs, queueLen, jobDoneChan}
<-jobDoneChan
}
}
}()
return ch
}
type FullBusStrategy struct {
Delay time.Duration
Batch int
queue *jobsQueue
busIsFull chan bool
}
func NewFullBusStrategy(delay time.Duration, batch int) *FullBusStrategy {
return &FullBusStrategy{
Delay: delay,
Batch: batch,
queue: newJobsQueue(),
busIsFull: make(chan bool),
}
}
func (ctx *FullBusStrategy) Queue(job *job) {
n := ctx.queue.Enqueue(job)
if n%ctx.Batch == 0 {
ctx.busIsFull <- true
}
}
// Формально, это описание логики водителя маршрутки xD
func (ctx *FullBusStrategy) GetJobs(abort context.Context) <-chan *JobsIteration {
ch := make(chan *JobsIteration)
go func() {
for {
t := time.NewTimer(ctx.Delay)
select {
case <-abort.Done():
close(ch)
return
case <-t.C:
ctx.sendJobs(ch)
case <-ctx.busIsFull:
t.Stop()
ctx.sendJobs(ch)
}
}
}()
return ch
}
func (ctx *FullBusStrategy) sendJobs(ch chan *JobsIteration) {
jobs, queueLen := ctx.queue.Dequeue(ctx.Batch)
ch <- &JobsIteration{jobs, queueLen, nil}
}
type BatchUuidsProvider struct {
context context.Context
emitter Emitter
strategy BatchUuidsProviderStrategy
onFirstCall sync.Once
}
func NewBatchUuidsProvider(
context context.Context,
strategy BatchUuidsProviderStrategy,
emitter Emitter,
) *BatchUuidsProvider {
return &BatchUuidsProvider{
context: context,
emitter: emitter,
strategy: strategy,
}
}
func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
ctx.onFirstCall.Do(ctx.startQueue)
resultChan := make(chan *jobResult)
ctx.strategy.Queue(&job{username, resultChan})
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:queued", username)
result := <-resultChan
return result.Profile, result.Error
}
func (ctx *BatchUuidsProvider) startQueue() {
// This synchronization chan is used to ensure that strategy's jobs provider
// will be initialized before any job will be scheduled
d := make(chan struct{})
go func() {
jobsChan := ctx.strategy.GetJobs(ctx.context)
close(d)
for {
select {
case <-ctx.context.Done():
return
case iteration := <-jobsChan:
go func() {
ctx.performRequest(iteration)
iteration.Done()
}()
}
}
}()
<-d
}
func (ctx *BatchUuidsProvider) performRequest(iteration *JobsIteration) {
usernames := make([]string, len(iteration.Jobs))
for i, job := range iteration.Jobs {
usernames[i] = job.Username
}
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:round", usernames, iteration.Queue)
if len(usernames) == 0 {
return
}
profiles, err := usernamesToUuids(usernames)
ctx.emitter.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err)
for _, job := range iteration.Jobs {
response := &jobResult{}
if err == nil {
// 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
}
}
} else {
response.Error = err
}
job.RespondChan <- response
close(job.RespondChan)
}
}

View File

@ -0,0 +1,441 @@
package mojangtextures
import (
"context"
"fmt"
"strconv"
"sync"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestJobsQueue(t *testing.T) {
t.Run("Enqueue", func(t *testing.T) {
s := newJobsQueue()
require.Equal(t, 1, s.Enqueue(&job{Username: "username1"}))
require.Equal(t, 2, s.Enqueue(&job{Username: "username2"}))
require.Equal(t, 3, s.Enqueue(&job{Username: "username3"}))
})
t.Run("Dequeue", func(t *testing.T) {
s := newJobsQueue()
s.Enqueue(&job{Username: "username1"})
s.Enqueue(&job{Username: "username2"})
s.Enqueue(&job{Username: "username3"})
s.Enqueue(&job{Username: "username4"})
s.Enqueue(&job{Username: "username5"})
items, queueLen := s.Dequeue(2)
require.Len(t, items, 2)
require.Equal(t, 3, queueLen)
require.Equal(t, "username1", items[0].Username)
require.Equal(t, "username2", items[1].Username)
items, queueLen = s.Dequeue(40)
require.Len(t, items, 3)
require.Equal(t, 0, queueLen)
require.Equal(t, "username3", items[0].Username)
require.Equal(t, "username4", items[1].Username)
require.Equal(t, "username5", items[2].Username)
})
}
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 manualStrategy struct {
ch chan *JobsIteration
once sync.Once
lock sync.Mutex
jobs []*job
}
func (m *manualStrategy) Queue(job *job) {
m.lock.Lock()
m.jobs = append(m.jobs, job)
m.lock.Unlock()
}
func (m *manualStrategy) GetJobs(_ context.Context) <-chan *JobsIteration {
m.lock.Lock()
defer m.lock.Unlock()
m.ch = make(chan *JobsIteration)
return m.ch
}
func (m *manualStrategy) Iterate(countJobsToReturn int, countLeftJobsInQueue int) {
m.lock.Lock()
defer m.lock.Unlock()
m.ch <- &JobsIteration{
Jobs: m.jobs[0:countJobsToReturn],
Queue: countLeftJobsInQueue,
}
}
type batchUuidsProviderGetUuidResult struct {
Result *mojang.ProfileInfo
Error error
}
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
Emitter *mockEmitter
Strategy *manualStrategy
MojangApi *mojangUsernamesToUuidsRequestMock
stop context.CancelFunc
}
func (suite *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
s := make(chan struct{})
// 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) {
close(s)
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Strategy = &manualStrategy{}
ctx, stop := context.WithCancel(context.Background())
suite.stop = stop
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
usernamesToUuids = suite.MojangApi.UsernamesToUuids
suite.Provider = NewBatchUuidsProvider(ctx, suite.Strategy, suite.Emitter)
}
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.stop()
suite.Emitter.AssertExpectations(suite.T())
suite.MojangApi.AssertExpectations(suite.T())
}
func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernames() {
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:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
expectedResult2,
}, nil)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Strategy.Iterate(2, 0)
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) TestShouldNotSendRequestWhenNoJobsAreReturned() {
//noinspection GoPreferNilSlice
emptyUsernames := []string{}
done := make(chan struct{})
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:round",
emptyUsernames,
1,
).Once().Run(func(args mock.Arguments) {
close(done)
})
suite.GetUuidAsync("username") // Schedule one username to run the queue
suite.Strategy.Iterate(0, 1) // Return no jobs and indicate that there is one job in queue
<-done
}
// Test written for multiple usernames to ensure that the error
// will be returned for each iteration group
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
expectedUsernames := []string{"username1", "username2"}
expectedError := &mojang.TooManyRequestsError{}
var nilProfilesResponse []*mojang.ProfileInfo
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.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Strategy.Iterate(2, 0)
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)
}
func TestPeriodicStrategy(t *testing.T) {
t.Run("should return first job only after duration", func(t *testing.T) {
d := 20 * time.Millisecond
strategy := NewPeriodicStrategy(d, 10)
j := &job{}
strategy.Queue(j)
ctx, cancel := context.WithCancel(context.Background())
startedAt := time.Now()
ch := strategy.GetJobs(ctx)
iteration := <-ch
durationBeforeResult := time.Now().Sub(startedAt)
require.True(t, durationBeforeResult >= d)
require.True(t, durationBeforeResult < d*2)
require.Equal(t, []*job{j}, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
cancel()
})
t.Run("should return the configured batch size", func(t *testing.T) {
strategy := NewPeriodicStrategy(0, 10)
jobs := make([]*job, 15)
for i := 0; i < 15; i++ {
jobs[i] = &job{Username: strconv.Itoa(i)}
strategy.Queue(jobs[i])
}
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
iteration := <-ch
require.Len(t, iteration.Jobs, 10)
require.Equal(t, jobs[0:10], iteration.Jobs)
require.Equal(t, 5, iteration.Queue)
cancel()
})
t.Run("should not return the next iteration until the previous one is finished", func(t *testing.T) {
strategy := NewPeriodicStrategy(0, 10)
strategy.Queue(&job{})
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
iteration := <-ch
require.Len(t, iteration.Jobs, 1)
require.Equal(t, 0, iteration.Queue)
time.Sleep(time.Millisecond) // Let strategy's internal loop to work (if the implementation is broken)
select {
case <-ch:
require.Fail(t, "the previous iteration isn't marked as done")
default:
// ok
}
iteration.Done()
time.Sleep(time.Millisecond) // Let strategy's internal loop to work
select {
case iteration = <-ch:
// ok
default:
require.Fail(t, "iteration should be provided")
}
require.Empty(t, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
iteration.Done()
cancel()
})
t.Run("each iteration should be returned only after the configured duration", func(t *testing.T) {
d := 5 * time.Millisecond
strategy := NewPeriodicStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
for i := 0; i < 3; i++ {
startedAt := time.Now()
iteration := <-ch
durationBeforeResult := time.Now().Sub(startedAt)
require.True(t, durationBeforeResult >= d)
require.True(t, durationBeforeResult < d*2)
require.Empty(t, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
// Sleep for at least doubled duration before calling Done() to check,
// that this duration isn't included into the next iteration time
time.Sleep(d * 2)
iteration.Done()
}
cancel()
})
}
func TestFullBusStrategy(t *testing.T) {
t.Run("should provide iteration immediately when the batch size exceeded", func(t *testing.T) {
jobs := make([]*job, 10)
for i := 0; i < 10; i++ {
jobs[i] = &job{}
}
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
select {
case iteration := <-ch:
require.Len(t, iteration.Jobs, 10)
require.Equal(t, 0, iteration.Queue)
case <-time.After(d):
require.Fail(t, "iteration should be provided immediately")
}
}()
for _, j := range jobs {
strategy.Queue(j)
}
<-done
cancel()
})
t.Run("should provide iteration after duration if batch size isn't exceeded", func(t *testing.T) {
jobs := make([]*job, 9)
for i := 0; i < 9; i++ {
jobs[i] = &job{}
}
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
startedAt := time.Now()
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
iteration := <-ch
duration := time.Now().Sub(startedAt)
require.True(t, duration >= d, fmt.Sprintf("has %d, expected %d", duration, d))
require.True(t, duration < d*2)
require.Equal(t, jobs, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
}()
for _, j := range jobs {
strategy.Queue(j)
}
<-done
cancel()
})
t.Run("should provide iteration as soon as the bus is full, without waiting for the previous iteration to finish", func(t *testing.T) {
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 3; i++ {
time.Sleep(5 * time.Millisecond) // See comment below
select {
case iteration := <-ch:
require.Len(t, iteration.Jobs, 10)
// Don't assert iteration.Queue length since it might be unstable
// Don't call iteration.Done()
case <-time.After(d):
t.Errorf("iteration should be provided as soon as the bus is full")
return
}
}
// Scheduled 31 tasks. 3 iterations should be performed immediately
// and should be executed only after timeout. The timeout above is used
// to increase overall time to ensure, that timer resets on every iteration
startedAt := time.Now()
iteration := <-ch
duration := time.Now().Sub(startedAt)
require.True(t, duration >= d)
require.True(t, duration < d*2)
require.Len(t, iteration.Jobs, 1)
require.Equal(t, 0, iteration.Queue)
}()
for i := 0; i < 31; i++ {
strategy.Queue(&job{})
}
<-done
cancel()
})
}

View File

@ -0,0 +1,94 @@
package mojangtextures
import (
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/utils"
)
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
}
type InMemoryTexturesStorage struct {
GCPeriod time.Duration
Duration time.Duration
once sync.Once
lock sync.RWMutex
data map[string]*inMemoryItem
done chan struct{}
}
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) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.RLock()
defer s.lock.RUnlock()
item, exists := s.data[uuid]
validRange := s.getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, nil
}
return item.textures, nil
}
func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.once.Do(s.start)
s.lock.Lock()
defer s.lock.Unlock()
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: utils.UnixMillisecond(time.Now()),
}
}
func (s *InMemoryTexturesStorage) start() {
s.done = make(chan struct{})
ticker := time.NewTicker(s.GCPeriod)
go func() {
for {
select {
case <-s.done:
return
case <-ticker.C:
s.gc()
}
}
}()
}
func (s *InMemoryTexturesStorage) Stop() {
close(s.done)
}
func (s *InMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
maxTime := s.getMinimalNotExpiredTimestamp()
for uuid, value := range s.data {
if maxTime > value.timestamp {
delete(s.data, uuid)
}
}
}
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1)))
}

View File

@ -0,0 +1,164 @@
package mojangtextures
import (
"testing"
"time"
assert "github.com/stretchr/testify/require"
"github.com/elyby/chrly/api/mojang"
)
var texturesWithSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{
Skin: &mojang.SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
},
},
}),
},
},
}
var texturesWithoutSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("should return nil, nil when textures are unavailable", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(t, result)
assert.Nil(t, err)
})
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("should return nil, nil when textures are exists, but cache duration is expired", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.Duration = 10 * time.Millisecond
storage.GCPeriod = time.Minute
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
time.Sleep(storage.Duration * 2)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(t, result)
assert.Nil(t, err)
})
}
func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("override already existed textures for uuid", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(t, texturesWithoutSkin, result)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("store textures with empty properties", func(t *testing.T) {
texturesWithEmptyProps := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{},
}
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithEmptyProps)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Exactly(t, texturesWithEmptyProps, result)
assert.Nil(t, err)
})
t.Run("store nil textures", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(t, result)
assert.Nil(t, err)
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
storage := NewInMemoryTexturesStorage()
defer storage.Stop()
storage.GCPeriod = 10 * time.Millisecond
storage.Duration = 9 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{},
}
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
// Store another texture a bit later to avoid it removing by GC after the first iteration
time.Sleep(2 * time.Millisecond)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.lock.RLock()
assert.Len(t, storage.data, 2, "the GC period has not yet reached")
storage.lock.RUnlock()
time.Sleep(storage.GCPeriod) // Let it perform the first GC iteration
storage.lock.RLock()
assert.Len(t, storage.data, 1, "the first texture should be cleaned by GC")
assert.Contains(t, storage.data, "b5d58475007d4f9e9ddd1403e2497579")
storage.lock.RUnlock()
time.Sleep(storage.GCPeriod) // Let another iteration happen
storage.lock.RLock()
assert.Len(t, storage.data, 0)
storage.lock.RUnlock()
}

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", uuid, result, err)
return result, err
}

View File

@ -0,0 +1,98 @@
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",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
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",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedResponse,
expectedError,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedError, err)
}

View File

@ -0,0 +1,205 @@
package mojangtextures
import (
"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.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV
var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{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, nil
}
username = strings.ToLower(username)
ctx.Emit("mojang_textures:call", username)
uuid, found, err := ctx.getUuidFromCache(username)
if err != nil {
return nil, err
}
if found && uuid == "" {
return nil, nil
}
if uuid != "" {
textures, err := ctx.getTexturesFromCache(uuid)
if err == nil && textures != 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, cachedUuid string) *broadcastResult {
uuid := cachedUuid
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 {
// Previously cached UUIDs may disappear
// In this case we must invalidate UUID cache for given username
if _, ok := err.(*mojang.EmptyResponse); ok && cachedUuid != "" {
return ctx.getResult(username, "")
}
return &broadcastResult{nil, err}
}
// 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, bool, error) {
ctx.Emit("mojang_textures:usernames:before_cache", username)
uuid, found, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, found, err)
return uuid, found, 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,457 @@
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, bool, error) {
args := m.Called(username)
return args.String(0), args.Bool(1), args.Error(2)
}
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", "", false, nil).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("", false, nil)
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", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
suite.Storage.On("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", true, 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", true, 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", "", true, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", true, 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", "", false, nil).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("", false, nil)
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", "", false, nil).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("", false, nil)
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)
}
// https://github.com/elyby/chrly/issues/29
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuidThatHasBeenDisappeared() {
expectedErr := &mojang.EmptyResponse{}
expectedProfile := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
var nilTexturesResponse *mojang.SignedTexturesResponse
expectedResult := &mojang.SignedTexturesResponse{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, expectedErr).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
suite.Storage.On("StoreUuid", "username", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once().Return(nil)
suite.Storage.On("StoreTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, expectedErr)
suite.TexturesProvider.On("GetTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
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", "", false, nil).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("", false, nil)
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().Nil(err)
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetErrorFromUUIDsStorage() {
expectedErr := 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", "", false, expectedErr).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", false, expectedErr)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedErr, err)
}
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", "", false, nil).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("", false, nil)
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", "", false, nil).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("", false, nil)
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,168 @@
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
}

53
mojangtextures/storage.go Normal file
View File

@ -0,0 +1,53 @@
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 {
// The second argument indicates whether a record was found in the storage,
// since depending on it, the empty value must be interpreted as "no cached record"
// or "value cached and has an empty value"
GetUuid(username string) (uuid string, found bool, err 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, bool, 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)
}

View File

@ -1,4 +1,4 @@
package queue
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
@ -12,9 +12,9 @@ type uuidsStorageMock struct {
mock.Mock
}
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
func (m *uuidsStorageMock) GetUuid(username string) (string, bool, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error {
@ -41,18 +41,19 @@ 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) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("GetUuid", "username").Once().Return("find me", nil)
result, err := storage.GetUuid("username")
uuidsMock.On("GetUuid", "username").Once().Return("find me", true, nil)
result, found, err := storage.GetUuid("username")
assert.Nil(t, err)
assert.True(t, found)
assert.Equal(t, "find me", result)
uuidsMock.AssertExpectations(t)
})
@ -82,8 +83,3 @@ func TestSplittedStorage(t *testing.T) {
texturesMock.AssertExpectations(t)
})
}
func TestValueNotFound_Error(t *testing.T) {
err := &ValueNotFound{}
assert.Equal(t, "value not found in the storage", err.Error())
}

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

Some files were not shown because too many files have changed in this diff Show More