mirror of
https://github.com/elyby/chrly.git
synced 2025-04-22 02:49:17 +05:30
Compare commits
139 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
cfe8fea3f7 | ||
|
27c7b79b32 | ||
|
ad31fdb709 | ||
|
fa62d45d00 | ||
|
cadb89f00a | ||
|
20ba78953b | ||
|
883a7bda3c | ||
|
d678f61df7 | ||
|
1543e98b87 | ||
|
0e0b41d6d7 | ||
|
3cd12acc1b | ||
|
dac4ed0ac6 | ||
|
11a779c670 | ||
|
6cb5e1eb42 | ||
|
8959e53270 | ||
|
3526570dd3 | ||
|
4b7f1346f5 | ||
|
e7721f9e5a | ||
|
26bfbd1517 | ||
|
ecbb06b83c | ||
|
6accffed45 | ||
|
980c920ceb | ||
|
32a9fee3e6 | ||
|
7cf5ae13be | ||
|
98d280240e | ||
|
26042037b6 | ||
|
1e3307dcbe | ||
|
d1d2c7ee6e | ||
|
2bc9f8eb57 | ||
|
6f148a8791 | ||
|
247499df6a | ||
|
3bf6872f3e | ||
|
60774b6b72 | ||
|
37cc8cda32 | ||
|
620bb95c74 | ||
|
fd05220299 | ||
|
dfe024756e | ||
|
66ef76ce6d | ||
|
aabf54e318 | ||
|
5dbe6af1d0 | ||
|
4c21fc5c90 | ||
|
2ea094bbf6 | ||
|
c4566a337b | ||
|
05c68c6ba6 | ||
|
8001eab9db | ||
|
33b286cba0 | ||
|
f997fdf9b0 | ||
|
be30c23823 | ||
|
f43c1a9a37 | ||
|
585318d307 | ||
|
b2e501af60 | ||
|
d8f6786c69 | ||
|
30c095525c | ||
|
436d98e1a0 | ||
|
1b9e943c0e | ||
|
29b6bc89b3 | ||
|
e08bb23b3d | ||
|
2d555d9253 | ||
|
dbefac0e84 | ||
|
15c6816813 | ||
|
bc2f9564d0 | ||
|
fbbb96603c | ||
|
06b61e1603 | ||
|
45a93deb24 | ||
|
eec830a828 | ||
|
7fb12f4a85 | ||
|
7978462540 | ||
|
2df31704c1 | ||
|
6453583e31 | ||
|
803f3f406b | ||
|
6c59ecbe2e | ||
|
a07905ca5a | ||
|
632ad4795a | ||
|
4ff164fffd | ||
|
5862d1cbf6 | ||
|
440b505306 | ||
|
a4cf29c797 | ||
|
ced4171eef | ||
|
e098b8d86f | ||
|
bca1436baf | ||
|
d9fbfe658a | ||
|
0be85b356b | ||
|
cc4cd2874c | ||
|
2ea4c55d37 | ||
|
f58b980948 | ||
|
3f81a0c18a | ||
|
9046338396 | ||
|
0c81494559 | ||
|
c9f6079d90 | ||
|
b0ba94751a | ||
|
2a5be658d8 | ||
|
153efdcce6 | ||
|
677f48ff3f | ||
|
db19fe62f2 | ||
|
f11dee57ff | ||
|
d526b74d07 | ||
|
270e93d39e | ||
|
53296c7015 | ||
|
092ea3d4e2 | ||
|
03c5a03c73 | ||
|
262babbeaa | ||
|
a459809b6b | ||
|
2fbeb492f0 | ||
|
0546b0519b | ||
|
767971a197 | ||
|
336fcdd072 | ||
|
49a1aaada0 | ||
|
bd13480175 | ||
|
20a8d90ad7 | ||
|
532f2206da | ||
|
280a55d553 | ||
|
c5e92e7a02 | ||
|
880182ccbf | ||
|
e3b9e3c069 | ||
|
e1c30a0ba1 | ||
|
40c53ea0d9 | ||
|
db728451f8 | ||
|
2abe2db469 | ||
|
b2ee10f72f | ||
|
fbfe9f4516 | ||
|
57b7c59929 | ||
|
0f2b000d70 | ||
|
af49eef84c | ||
|
92473d15d6 | ||
|
bc1427dd1f | ||
|
a8e4f7ae56 | ||
|
17f82ec6d3 | ||
|
9946eae73b | ||
|
a4a9201034 | ||
|
7f9b60ab3a | ||
|
5a0c10c1a1 | ||
|
1e91aef0a6 | ||
|
1033069211 | ||
|
d27caa4922 | ||
|
0644dfe021 | ||
|
6fd88e077e | ||
|
ae185e1daa | ||
|
7353047467 | ||
|
b2a1fd450b |
53
.github/workflows/build.yml
vendored
Normal file
53
.github/workflows/build.yml
vendored
Normal 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
66
.github/workflows/release.yml
vendored
Normal 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 }}
|
42
.travis.yml
42
.travis.yml
@ -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
|
125
CHANGELOG.md
125
CHANGELOG.md
@ -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
|
||||
|
29
Dockerfile
29
Dockerfile
@ -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
334
Gopkg.lock
generated
@ -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
|
51
Gopkg.toml
51
Gopkg.toml
@ -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"
|
2
LICENSE
2
LICENSE
@ -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
291
README.md
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
@ -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{})
|
||||
})
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
@ -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))
|
||||
}
|
@ -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]
|
||||
}
|
@ -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"
|
||||
}
|
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
36
cmd/root.go
36
cmd/root.go
@ -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
56
cmd/root_profiling.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
76
cmd/serve.go
76
cmd/serve.go
@ -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")
|
||||
}
|
||||
|
13
cmd/token.go
13
cmd/token.go
@ -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)
|
||||
}
|
||||
|
@ -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
17
cmd/worker.go
Normal 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)
|
||||
}
|
@ -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."
|
||||
}
|
@ -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
|
||||
}
|
@ -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
33
db/fs/fs.go
Normal 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
|
||||
}
|
56
db/fs/fs_integration_test.go
Normal file
56
db/fs/fs_integration_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
307
db/redis.go
307
db/redis.go
@ -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
315
db/redis/redis.go
Normal 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
|
||||
}
|
425
db/redis/redis_integration_test.go
Normal file
425
db/redis/redis_integration_test.go
Normal 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
14
di/config.go
Normal 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
72
di/db.go
Normal 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
21
di/di.go
Normal 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
36
di/dispatcher.go
Normal 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
163
di/handlers.go
Normal 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
104
di/logger.go
Normal 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
235
di/mojang_textures.go
Normal 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
79
di/server.go
Normal 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
48
di/signer.go
Normal file
@ -0,0 +1,48 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"github.com/elyby/chrly/http"
|
||||
. "github.com/elyby/chrly/signer"
|
||||
"strings"
|
||||
|
||||
"github.com/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
34
dispatcher/dispatcher.go
Normal 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(),
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
111
eventsubscribers/health_checkers.go
Normal file
111
eventsubscribers/health_checkers.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
148
eventsubscribers/health_checkers_test.go
Normal file
148
eventsubscribers/health_checkers_test.go
Normal 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()))
|
||||
})
|
||||
}
|
94
eventsubscribers/logger.go
Normal file
94
eventsubscribers/logger.go
Normal 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]
|
||||
}
|
256
eventsubscribers/logger_test.go
Normal file
256
eventsubscribers/logger_test.go
Normal 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:]...)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
190
eventsubscribers/stats_reporter.go
Normal file
190
eventsubscribers/stats_reporter.go
Normal 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))
|
||||
}
|
402
eventsubscribers/stats_reporter_test.go
Normal file
402
eventsubscribers/stats_reporter_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
7
eventsubscribers/subscriber.go
Normal file
7
eventsubscribers/subscriber.go
Normal file
@ -0,0 +1,7 @@
|
||||
package eventsubscribers
|
||||
|
||||
import "github.com/elyby/chrly/dispatcher"
|
||||
|
||||
type Subscriber interface {
|
||||
dispatcher.Subscriber
|
||||
}
|
55
go.mod
Normal file
55
go.mod
Normal 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
106
go.sum
Normal 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=
|
256
http/api.go
256
http/api.go
@ -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)
|
||||
}
|
||||
|
659
http/api_test.go
659
http/api_test.go
@ -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==")
|
||||
|
51
http/cape.go
51
http/cape.go
@ -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)
|
||||
}
|
@ -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
|
||||
}
|
177
http/http.go
177
http/http.go
@ -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)
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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
127
http/jwt_test.go
Normal 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)
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
@ -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))
|
||||
}
|
@ -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)
|
||||
}
|
@ -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))
|
||||
})
|
||||
}
|
49
http/skin.go
49
http/skin.go
@ -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)
|
||||
}
|
@ -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
405
http/skinsystem.go
Normal 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
1286
http/skinsystem_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
@ -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
53
http/uuids_worker.go
Normal 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
154
http/uuids_worker_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
package interfaces
|
||||
|
||||
import "net/http"
|
||||
|
||||
type AuthChecker interface {
|
||||
Check(req *http.Request) error
|
||||
}
|
@ -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)
|
||||
}
|
@ -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)
|
||||
}
|
@ -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...)
|
||||
}
|
@ -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
|
||||
}
|
@ -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"`
|
||||
|
249
mojangtextures/batch_uuids_provider.go
Normal file
249
mojangtextures/batch_uuids_provider.go
Normal 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)
|
||||
}
|
||||
}
|
441
mojangtextures/batch_uuids_provider_test.go
Normal file
441
mojangtextures/batch_uuids_provider_test.go
Normal 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()
|
||||
})
|
||||
}
|
94
mojangtextures/in_memory_textures_storage.go
Normal file
94
mojangtextures/in_memory_textures_storage.go
Normal 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)))
|
||||
}
|
164
mojangtextures/in_memory_textures_storage_test.go
Normal file
164
mojangtextures/in_memory_textures_storage_test.go
Normal 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()
|
||||
}
|
19
mojangtextures/mojang_api_textures_provider.go
Normal file
19
mojangtextures/mojang_api_textures_provider.go
Normal 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
|
||||
}
|
98
mojangtextures/mojang_api_textures_provider_test.go
Normal file
98
mojangtextures/mojang_api_textures_provider_test.go
Normal 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)
|
||||
}
|
205
mojangtextures/mojang_textures.go
Normal file
205
mojangtextures/mojang_textures.go
Normal 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
|
||||
}
|
457
mojangtextures/mojang_textures_test.go
Normal file
457
mojangtextures/mojang_textures_test.go
Normal 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)
|
||||
}
|
12
mojangtextures/nil_mojang_textures.go
Normal file
12
mojangtextures/nil_mojang_textures.go
Normal 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
|
||||
}
|
14
mojangtextures/nil_mojang_textures_test.go
Normal file
14
mojangtextures/nil_mojang_textures_test.go
Normal 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)
|
||||
}
|
67
mojangtextures/remote_api_uuids_provider.go
Normal file
67
mojangtextures/remote_api_uuids_provider.go
Normal 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"
|
||||
}
|
168
mojangtextures/remote_api_uuids_provider_test.go
Normal file
168
mojangtextures/remote_api_uuids_provider_test.go
Normal 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
53
mojangtextures/storage.go
Normal 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)
|
||||
}
|
@ -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())
|
||||
}
|
@ -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
|
@ -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
|
27
script/test
27
script/test
@ -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
Loading…
x
Reference in New Issue
Block a user