mirror of
https://github.com/elyby/chrly.git
synced 2025-05-31 14:11:51 +05:30
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc3d3bb419 | ||
|
|
716ec8bd37 | ||
|
|
62b6ac8083 | ||
|
|
ce6e62ae5c | ||
|
|
32d749f245 | ||
|
|
680effa47a | ||
|
|
4e9a145f74 | ||
|
|
b9a38dd947 | ||
|
|
7964281f06 | ||
|
|
528b131309 | ||
|
|
436ff7c294 | ||
|
|
f037fb11e1 | ||
|
|
feb8e32069 | ||
|
|
f5bc474b4d | ||
|
|
fdafbc4f0e | ||
|
|
cecd07c113 | ||
|
|
5d7a66311d | ||
|
|
d363433c88 | ||
|
|
bc4d714112 | ||
|
|
10c11bc060 | ||
|
|
11340289ad | ||
|
|
06afd17557 | ||
|
|
c95ecc2491 | ||
|
|
77e466cc0d | ||
|
|
dac3ca9001 | ||
|
|
dac5e4967f | ||
|
|
4cdc151ab3 | ||
|
|
ad31fdb709 | ||
|
|
fa62d45d00 | ||
|
|
cadb89f00a | ||
|
|
e568d4cf91 | ||
|
|
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 | ||
|
|
334e60ff2f | ||
|
|
6d6d0e4b79 |
@@ -1,2 +0,0 @@
|
||||
data
|
||||
vendor
|
||||
79
.github/workflows/build.yml
vendored
Normal file
79
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
|
||||
- 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: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
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
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: 'false'
|
||||
run: >
|
||||
go build
|
||||
-trimpath
|
||||
-ldflags "-w -s -X ely.by/chrly/internal/version.version=${{ steps.version.outputs.version }} -X ely.by/chrly/internal/version.commit=${{ github.sha }}"
|
||||
-o ./chrly ./cmd/chrly/...
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: chrly-build-linux-amd64-${{ steps.version.outputs.version }}
|
||||
path: ./chrly
|
||||
compression-level: 0
|
||||
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 }}
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,5 +1,11 @@
|
||||
.idea
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
vendor
|
||||
.cover
|
||||
# IDE files
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode
|
||||
|
||||
# Go mod vendoring
|
||||
/vendor
|
||||
|
||||
# Local environment
|
||||
/docker-compose.yml
|
||||
/data
|
||||
|
||||
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
|
||||
131
CHANGELOG.md
131
CHANGELOG.md
@@ -5,6 +5,129 @@ 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
|
||||
|
||||
### 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`
|
||||
- Worker mode. Use URL spoofing to load balance outgoing requests.
|
||||
|
||||
## [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
|
||||
- Mojang's textures queue batch size [reduced to 10](https://wiki.vg/index.php?title=Mojang_API&type=revision&diff=14964&oldid=14954).
|
||||
- 400 BadRequest errors from the Mojang's API are now logged.
|
||||
|
||||
## [4.2.2] - 2019-06-19
|
||||
### Fixed
|
||||
@@ -61,7 +184,13 @@ 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.2...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
|
||||
[4.2.0]: https://github.com/elyby/chrly/compare/4.1.1...4.2.0
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,14 +0,0 @@
|
||||
FROM alpine:3.9.3
|
||||
|
||||
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/
|
||||
|
||||
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.
|
||||
|
||||
249
README.md
249
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,124 @@ 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>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 +209,75 @@ 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": "signature value",
|
||||
"value": "base64 encoded value"
|
||||
},
|
||||
{
|
||||
"name": "chrly",
|
||||
"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 +328,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 +344,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 +383,44 @@ response will be:
|
||||
}
|
||||
```
|
||||
|
||||
### 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 +446,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
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var HttpClient = &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConnsPerHost: 1024,
|
||||
},
|
||||
}
|
||||
|
||||
type SignedTexturesResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Props []*Property `json:"properties"`
|
||||
decodedTextures *TexturesProp
|
||||
}
|
||||
|
||||
func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
|
||||
if t.decodedTextures == nil {
|
||||
var texturesProp string
|
||||
for _, prop := range t.Props {
|
||||
if prop.Name == "textures" {
|
||||
texturesProp = prop.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if texturesProp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
decodedTextures, _ := DecodeTextures(texturesProp)
|
||||
t.decodedTextures = decodedTextures
|
||||
}
|
||||
|
||||
return t.decodedTextures
|
||||
}
|
||||
|
||||
type Property struct {
|
||||
Name string `json:"name"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ProfileInfo struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsLegacy bool `json:"legacy,omitempty"`
|
||||
IsDemo bool `json:"demo,omitempty"`
|
||||
}
|
||||
|
||||
// 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.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := HttpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if responseErr := validateResponse(response); responseErr != nil {
|
||||
return nil, responseErr
|
||||
}
|
||||
|
||||
var result []*ProfileInfo
|
||||
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
if signed {
|
||||
url += "?unsigned=false"
|
||||
}
|
||||
|
||||
request, _ := http.NewRequest("GET", url, nil)
|
||||
|
||||
response, err := HttpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if responseErr := validateResponse(response); responseErr != nil {
|
||||
return nil, responseErr
|
||||
}
|
||||
|
||||
var result *SignedTexturesResponse
|
||||
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateResponse(response *http.Response) error {
|
||||
switch {
|
||||
case response.StatusCode == 204:
|
||||
return &EmptyResponse{}
|
||||
case response.StatusCode == 400:
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
var decodedError *errorResponse
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
_ = json.Unmarshal(body, &decodedError)
|
||||
|
||||
return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message}
|
||||
case response.StatusCode == 429:
|
||||
return &TooManyRequestsError{}
|
||||
case response.StatusCode >= 500:
|
||||
return &ServerError{Status: response.StatusCode}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ResponseError interface {
|
||||
IsMojangError() bool
|
||||
}
|
||||
|
||||
// Mojang API doesn't return a 404 Not Found error for non-existent data identifiers
|
||||
// Instead, they return 204 with an empty body
|
||||
type EmptyResponse struct {
|
||||
}
|
||||
|
||||
func (*EmptyResponse) Error() string {
|
||||
return "Empty Response"
|
||||
}
|
||||
|
||||
func (*EmptyResponse) IsMojangError() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// When passed request params are invalid, Mojang returns 400 Bad Request error
|
||||
type BadRequestError struct {
|
||||
ResponseError
|
||||
ErrorType string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *BadRequestError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (*BadRequestError) IsMojangError() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
func (*TooManyRequestsError) IsMojangError() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ServerError happens when Mojang's API returns any response with 50* status
|
||||
type ServerError struct {
|
||||
ResponseError
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *ServerError) Error() string {
|
||||
return "Server error"
|
||||
}
|
||||
|
||||
func (*ServerError) IsMojangError() bool {
|
||||
return true
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/h2non/gock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSignedTexturesResponse(t *testing.T) {
|
||||
t.Run("DecodeTextures", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
},
|
||||
},
|
||||
}
|
||||
textures := obj.DecodeTextures()
|
||||
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
|
||||
})
|
||||
|
||||
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
|
||||
obj := &SignedTexturesResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{},
|
||||
}
|
||||
textures := obj.DecodeTextures()
|
||||
testify.Nil(t, textures)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUsernamesToUuids(t *testing.T) {
|
||||
t.Run("exchange usernames to uuids", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
JSON([]string{"Thinkofdeath", "maksimkurb"}).
|
||||
Reply(200).
|
||||
JSON([]map[string]interface{}{
|
||||
{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"legacy": false,
|
||||
"demo": true,
|
||||
},
|
||||
{
|
||||
"id": "0d252b7218b648bfb86c2ae476954d32",
|
||||
"name": "maksimkurb",
|
||||
// There is no legacy or demo fields
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
if assert.NoError(err) {
|
||||
assert.Len(result, 2)
|
||||
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
|
||||
assert.Equal("Thinkofdeath", result[0].Name)
|
||||
assert.False(result[0].IsLegacy)
|
||||
assert.True(result[0].IsDemo)
|
||||
|
||||
assert.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
|
||||
assert.Equal("maksimkurb", result[1].Name)
|
||||
assert.False(result[1].IsLegacy)
|
||||
assert.False(result[1].IsDemo)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handle bad request response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(400).
|
||||
JSON(map[string]interface{}{
|
||||
"error": "IllegalArgumentException",
|
||||
"errorMessage": "profileName can not be null or empty.",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{""})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&BadRequestError{}, err)
|
||||
assert.EqualError(err, "profileName can not be null or empty.")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle too many requests response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(429).
|
||||
JSON(map[string]interface{}{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&TooManyRequestsError{}, err)
|
||||
assert.EqualError(err, "Too Many Requests")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle server error", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
|
||||
assert.Nil(result)
|
||||
assert.IsType(&ServerError{}, err)
|
||||
assert.EqualError(err, "Server error")
|
||||
assert.Equal(500, err.(*ServerError).Status)
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUuidToTextures(t *testing.T) {
|
||||
t.Run("obtain not signed textures", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "textures",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
if assert.NoError(err) {
|
||||
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
assert.Equal("Thinkofdeath", result.Name)
|
||||
assert.Equal(1, len(result.Props))
|
||||
assert.Equal("textures", result.Props[0].Name)
|
||||
assert.Equal(476, len(result.Props[0].Value))
|
||||
assert.Equal("", result.Props[0].Signature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("obtain signed textures", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
MatchParam("unsigned", "false").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "textures",
|
||||
"signature": "signature string",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", true)
|
||||
if assert.NoError(err) {
|
||||
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
assert.Equal("Thinkofdeath", result.Name)
|
||||
assert.Equal(1, len(result.Props))
|
||||
assert.Equal("textures", result.Props[0].Name)
|
||||
assert.Equal(476, len(result.Props[0].Value))
|
||||
assert.Equal("signature string", result.Props[0].Signature)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handle empty response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(204).
|
||||
BodyString("")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
assert.Nil(result)
|
||||
assert.IsType(&EmptyResponse{}, err)
|
||||
assert.EqualError(err, "Empty Response")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle too many requests response", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(429).
|
||||
JSON(map[string]interface{}{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
assert.Nil(result)
|
||||
assert.IsType(&TooManyRequestsError{}, err)
|
||||
assert.EqualError(err, "Too Many Requests")
|
||||
assert.Implements((*ResponseError)(nil), err)
|
||||
})
|
||||
|
||||
t.Run("handle server error", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
HttpClient = client
|
||||
|
||||
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
assert.Nil(result)
|
||||
assert.IsType(&ServerError{}, err)
|
||||
assert.EqualError(err, "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,209 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mono83/slf/wd"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
)
|
||||
|
||||
var usernamesToUuids = mojang.UsernamesToUuids
|
||||
var uuidToTextures = mojang.UuidToTextures
|
||||
var uuidsQueueIterationDelay = time.Second
|
||||
var forever = func() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// https://help.mojang.com/customer/portal/articles/928638
|
||||
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
|
||||
|
||||
type JobsQueue struct {
|
||||
Storage Storage
|
||||
Logger wd.Watchdog
|
||||
|
||||
onFirstCall sync.Once
|
||||
queue jobsQueue
|
||||
broadcast *broadcastMap
|
||||
}
|
||||
|
||||
func (ctx *JobsQueue) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse {
|
||||
// TODO: convert username to lower case
|
||||
ctx.onFirstCall.Do(func() {
|
||||
ctx.queue.New()
|
||||
ctx.broadcast = newBroadcaster()
|
||||
ctx.startQueue()
|
||||
})
|
||||
|
||||
responseChan := make(chan *mojang.SignedTexturesResponse)
|
||||
if !allowedUsernamesRegex.MatchString(username) {
|
||||
ctx.Logger.IncCounter("mojang_textures.invalid_username", 1)
|
||||
go func() {
|
||||
responseChan <- nil
|
||||
close(responseChan)
|
||||
}()
|
||||
|
||||
return responseChan
|
||||
}
|
||||
|
||||
ctx.Logger.IncCounter("mojang_textures.request", 1)
|
||||
|
||||
uuid, err := ctx.Storage.GetUuid(username)
|
||||
if err == nil && uuid == "" {
|
||||
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
|
||||
|
||||
go func() {
|
||||
responseChan <- nil
|
||||
close(responseChan)
|
||||
}()
|
||||
|
||||
return responseChan
|
||||
}
|
||||
|
||||
isFirstListener := ctx.broadcast.AddListener(username, responseChan)
|
||||
if isFirstListener {
|
||||
start := time.Now()
|
||||
// TODO: respond nil if processing takes more than 5 seconds
|
||||
|
||||
resultChan := make(chan *mojang.SignedTexturesResponse)
|
||||
if uuid == "" {
|
||||
ctx.Logger.IncCounter("mojang_textures.usernames.queued", 1)
|
||||
ctx.queue.Enqueue(&jobItem{username, resultChan})
|
||||
} else {
|
||||
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit", 1)
|
||||
go func() {
|
||||
resultChan <- ctx.getTextures(uuid)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
result := <-resultChan
|
||||
close(resultChan)
|
||||
ctx.broadcast.BroadcastAndRemove(username, result)
|
||||
ctx.Logger.RecordTimer("mojang_textures.result_time", time.Since(start))
|
||||
}()
|
||||
} else {
|
||||
ctx.Logger.IncCounter("mojang_textures.already_in_queue", 1)
|
||||
}
|
||||
|
||||
return responseChan
|
||||
}
|
||||
|
||||
func (ctx *JobsQueue) startQueue() {
|
||||
go func() {
|
||||
time.Sleep(uuidsQueueIterationDelay)
|
||||
for forever() {
|
||||
start := time.Now()
|
||||
ctx.queueRound()
|
||||
elapsed := time.Since(start)
|
||||
ctx.Logger.RecordTimer("mojang_textures.usernames.round_time", elapsed)
|
||||
time.Sleep(uuidsQueueIterationDelay)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ctx *JobsQueue) queueRound() {
|
||||
if ctx.queue.IsEmpty() {
|
||||
return
|
||||
}
|
||||
|
||||
queueSize := ctx.queue.Size()
|
||||
jobs := ctx.queue.Dequeue(100)
|
||||
ctx.Logger.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(jobs)))
|
||||
ctx.Logger.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize-len(jobs)))
|
||||
var usernames []string
|
||||
for _, job := range jobs {
|
||||
usernames = append(usernames, job.Username)
|
||||
}
|
||||
|
||||
profiles, err := usernamesToUuids(usernames)
|
||||
if err != nil {
|
||||
ctx.handleResponseError(err, "usernames")
|
||||
for _, job := range jobs {
|
||||
job.RespondTo <- nil
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
go func(job *jobItem) {
|
||||
var uuid string
|
||||
// The profiles in the response are not ordered, so we must search each username over full array
|
||||
for _, profile := range profiles {
|
||||
if strings.EqualFold(job.Username, profile.Name) {
|
||||
uuid = profile.Id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_ = ctx.Storage.StoreUuid(job.Username, uuid)
|
||||
if uuid == "" {
|
||||
job.RespondTo <- nil
|
||||
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_miss", 1)
|
||||
} else {
|
||||
job.RespondTo <- ctx.getTextures(uuid)
|
||||
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_hit", 1)
|
||||
}
|
||||
}(job)
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *JobsQueue) getTextures(uuid string) *mojang.SignedTexturesResponse {
|
||||
existsTextures, err := ctx.Storage.GetTextures(uuid)
|
||||
if err == nil {
|
||||
ctx.Logger.IncCounter("mojang_textures.textures.cache_hit", 1)
|
||||
return existsTextures
|
||||
}
|
||||
|
||||
ctx.Logger.IncCounter("mojang_textures.textures.request", 1)
|
||||
|
||||
start := time.Now()
|
||||
result, err := uuidToTextures(uuid, true)
|
||||
ctx.Logger.RecordTimer("mojang_textures.textures.request_time", time.Since(start))
|
||||
if err != nil {
|
||||
ctx.handleResponseError(err, "textures")
|
||||
}
|
||||
|
||||
// Mojang can respond with an error, but count it as a hit, so store result even if the textures is nil
|
||||
ctx.Storage.StoreTextures(uuid, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (ctx *JobsQueue) handleResponseError(err error, threadName string) {
|
||||
ctx.Logger.Debug(":name: Got response error :err", wd.NameParam(threadName), wd.ErrParam(err))
|
||||
|
||||
switch err.(type) {
|
||||
case mojang.ResponseError:
|
||||
if _, ok := err.(*mojang.TooManyRequestsError); ok {
|
||||
ctx.Logger.Warning(":name: Got 429 Too Many Requests :err", wd.NameParam(threadName), wd.ErrParam(err))
|
||||
}
|
||||
|
||||
return
|
||||
case net.Error:
|
||||
if err.(net.Error).Timeout() {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := err.(*url.Error); ok {
|
||||
return
|
||||
}
|
||||
|
||||
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
|
||||
return
|
||||
}
|
||||
|
||||
if err == syscall.ECONNREFUSED {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Logger.Emergency(":name: Unknown Mojang response error: :err", wd.NameParam(threadName), wd.ErrParam(err))
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
mocks "github.com/elyby/chrly/tests"
|
||||
)
|
||||
|
||||
type mojangApiMocks struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (o *mojangApiMocks) UsernamesToUuids(usernames []string) ([]*mojang.ProfileInfo, error) {
|
||||
args := o.Called(usernames)
|
||||
var result []*mojang.ProfileInfo
|
||||
if casted, ok := args.Get(0).([]*mojang.ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func (o *mojangApiMocks) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) {
|
||||
args := o.Called(uuid, signed)
|
||||
var result *mojang.SignedTexturesResponse
|
||||
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type mockStorage struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockStorage) GetUuid(username string) (string, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockStorage) StoreUuid(username string, uuid string) error {
|
||||
args := m.Called(username, uuid)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *mockStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
|
||||
args := m.Called(uuid)
|
||||
var result *mojang.SignedTexturesResponse
|
||||
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *mockStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
|
||||
m.Called(uuid, textures)
|
||||
}
|
||||
|
||||
type queueTestSuite struct {
|
||||
suite.Suite
|
||||
Queue *JobsQueue
|
||||
Storage *mockStorage
|
||||
MojangApi *mojangApiMocks
|
||||
Logger *mocks.WdMock
|
||||
Iterate func()
|
||||
|
||||
iterateChan chan bool
|
||||
done func()
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) SetupSuite() {
|
||||
uuidsQueueIterationDelay = 0
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) SetupTest() {
|
||||
suite.Storage = &mockStorage{}
|
||||
suite.Logger = &mocks.WdMock{}
|
||||
|
||||
suite.Queue = &JobsQueue{Storage: suite.Storage, Logger: suite.Logger}
|
||||
|
||||
suite.iterateChan = make(chan bool)
|
||||
forever = func() bool {
|
||||
return <-suite.iterateChan
|
||||
}
|
||||
|
||||
suite.Iterate = func() {
|
||||
suite.iterateChan <- true
|
||||
}
|
||||
|
||||
suite.done = func() {
|
||||
suite.iterateChan <- false
|
||||
}
|
||||
|
||||
suite.MojangApi = new(mojangApiMocks)
|
||||
usernamesToUuids = suite.MojangApi.UsernamesToUuids
|
||||
uuidToTextures = suite.MojangApi.UuidToTextures
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TearDownTest() {
|
||||
suite.done()
|
||||
time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls
|
||||
suite.MojangApi.AssertExpectations(suite.T())
|
||||
suite.Storage.AssertExpectations(suite.T())
|
||||
suite.Logger.AssertExpectations(suite.T())
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForOneUsernameWithoutAnyCache() {
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
|
||||
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
|
||||
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
|
||||
}, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
suite.Iterate()
|
||||
|
||||
result := <-resultChan
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForFewUsernamesWithoutAnyCache() {
|
||||
expectedResult1 := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
|
||||
expectedResult2 := &mojang.SignedTexturesResponse{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Twice()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
|
||||
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Twice()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Twice()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Twice()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Twice()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
|
||||
suite.Storage.On("GetUuid", "Thinkofdeath").Once().Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
|
||||
suite.Storage.On("StoreUuid", "Thinkofdeath", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil)
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("GetTextures", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult1).Once()
|
||||
suite.Storage.On("StoreTextures", "4566e69fc90748ee8d71d7ba5aa00d20", expectedResult2).Once()
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb", "Thinkofdeath"}).Once().Return([]*mojang.ProfileInfo{
|
||||
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
|
||||
{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"},
|
||||
}, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult1, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "4566e69fc90748ee8d71d7ba5aa00d20", true).Once().Return(expectedResult2, nil)
|
||||
|
||||
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
resultChan2 := suite.Queue.GetTexturesForUsername("Thinkofdeath")
|
||||
|
||||
suite.Iterate()
|
||||
|
||||
suite.Assert().Equal(expectedResult1, <-resultChan1)
|
||||
suite.Assert().Equal(expectedResult2, <-resultChan2)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUuid() {
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil)
|
||||
// Storage.StoreUuid shouldn't be called
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
|
||||
|
||||
// MojangApi.UsernamesToUuids shouldn't be called
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
// Note that there is no iteration
|
||||
|
||||
result := <-resultChan
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithFullyCachedResult() {
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.textures.cache_hit", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil)
|
||||
// Storage.StoreUuid shouldn't be called
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(expectedResult, nil)
|
||||
// Storage.StoreTextures shouldn't be called
|
||||
|
||||
// MojangApi.UsernamesToUuids shouldn't be called
|
||||
// MojangApi.UuidToTextures shouldn't be called
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
// Note that there is no iteration
|
||||
|
||||
result := <-resultChan
|
||||
suite.Assert().Equal(expectedResult, result)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUnknownUuid() {
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", nil)
|
||||
// Storage.StoreUuid shouldn't be called
|
||||
// Storage.GetTextures shouldn't be called
|
||||
// Storage.StoreTextures shouldn't be called
|
||||
|
||||
// MojangApi.UsernamesToUuids shouldn't be called
|
||||
// MojangApi.UuidToTextures shouldn't be called
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
// Note that there is no iteration
|
||||
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForMoreThan100Usernames() {
|
||||
usernames := make([]string, 120)
|
||||
for i := 0; i < 120; i++ {
|
||||
usernames[i] = randStr(8)
|
||||
}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Times(120)
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Times(120)
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(100)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(20)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(20)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Twice()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Times(120)
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Times(120)
|
||||
|
||||
suite.Storage.On("GetUuid", mock.Anything).Times(120).Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", mock.Anything, "").Times(120).Return(nil) // should be called with "" if username is not compared to uuid
|
||||
// Storage.GetTextures and Storage.SetTextures shouldn't be called
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", usernames[0:100]).Once().Return([]*mojang.ProfileInfo{}, nil)
|
||||
suite.MojangApi.On("UsernamesToUuids", usernames[100:120]).Once().Return([]*mojang.ProfileInfo{}, nil)
|
||||
|
||||
channels := make([]chan *mojang.SignedTexturesResponse, 120)
|
||||
for i, username := range usernames {
|
||||
channels[i] = suite.Queue.GetTexturesForUsername(username)
|
||||
}
|
||||
|
||||
suite.Iterate()
|
||||
suite.Iterate()
|
||||
|
||||
for _, channel := range channels {
|
||||
<-channel
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForTheSameUsernames() {
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
|
||||
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
|
||||
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
|
||||
}, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
|
||||
|
||||
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
suite.Iterate()
|
||||
|
||||
suite.Assert().Equal(expectedResult, <-resultChan1)
|
||||
suite.Assert().Equal(expectedResult, <-resultChan2)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForUsernameThatAlreadyProcessing() {
|
||||
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
|
||||
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
|
||||
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
|
||||
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
|
||||
}, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).
|
||||
Once().
|
||||
After(10*time.Millisecond). // Simulate long round trip
|
||||
Return(expectedResult, nil)
|
||||
|
||||
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
// Note that for entire test there is only one iteration
|
||||
suite.Iterate()
|
||||
|
||||
// Let it meet delayed UuidToTextures request
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
suite.Assert().Equal(expectedResult, <-resultChan1)
|
||||
suite.Assert().Equal(expectedResult, <-resultChan2)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestDoNothingWhenNoTasks() {
|
||||
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
|
||||
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
|
||||
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Once()
|
||||
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "").Once().Return(nil)
|
||||
// Storage.GetTextures and Storage.StoreTextures shouldn't be called
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{}, nil)
|
||||
|
||||
// Perform first iteration and await it finish
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
|
||||
suite.Iterate()
|
||||
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
|
||||
// Let it to perform a few more iterations to ensure, that there is no calls to external APIs
|
||||
suite.Iterate()
|
||||
suite.Iterate()
|
||||
}
|
||||
|
||||
type timeoutError struct {
|
||||
}
|
||||
|
||||
func (*timeoutError) Error() string { return "timeout error" }
|
||||
func (*timeoutError) Timeout() bool { return true }
|
||||
func (*timeoutError) Temporary() bool { return false }
|
||||
|
||||
var expectedErrors = []error{
|
||||
&mojang.BadRequestError{},
|
||||
&mojang.TooManyRequestsError{},
|
||||
&mojang.ServerError{},
|
||||
&timeoutError{},
|
||||
&url.Error{Op: "GET", URL: "http://localhost"},
|
||||
&net.OpError{Op: "read"},
|
||||
&net.OpError{Op: "dial"},
|
||||
syscall.ECONNREFUSED,
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUsernameToUuidRequest() {
|
||||
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
|
||||
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
|
||||
|
||||
for _, err := range expectedErrors {
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, err)
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
suite.Iterate()
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
suite.MojangApi.AssertExpectations(suite.T())
|
||||
suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUsernameToUuidRequest() {
|
||||
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
|
||||
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, errors.New("unexpected error"))
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
suite.Iterate()
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUuidToTexturesRequest() {
|
||||
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
|
||||
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Return(nil)
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", (*mojang.SignedTexturesResponse)(nil))
|
||||
|
||||
for _, err := range expectedErrors {
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
|
||||
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
|
||||
}, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, err)
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
suite.Iterate()
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
suite.MojangApi.AssertExpectations(suite.T())
|
||||
suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUuidToTexturesRequest() {
|
||||
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
|
||||
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
|
||||
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
|
||||
|
||||
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
|
||||
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Return(nil)
|
||||
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{})
|
||||
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", (*mojang.SignedTexturesResponse)(nil))
|
||||
|
||||
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
|
||||
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
|
||||
}, nil)
|
||||
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, errors.New("unexpected error"))
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
|
||||
suite.Iterate()
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
}
|
||||
|
||||
func (suite *queueTestSuite) TestReceiveTexturesForNotAllowedMojangUsername() {
|
||||
suite.Logger.On("IncCounter", "mojang_textures.invalid_username", int64(1)).Once()
|
||||
|
||||
resultChan := suite.Queue.GetTexturesForUsername("Not allowed")
|
||||
suite.Assert().Nil(<-resultChan)
|
||||
}
|
||||
|
||||
func TestJobsQueueSuite(t *testing.T) {
|
||||
suite.Run(t, new(queueTestSuite))
|
||||
}
|
||||
|
||||
var replacer = strings.NewReplacer("-", "_", "=", "")
|
||||
|
||||
// https://stackoverflow.com/a/50581165
|
||||
func randStr(len int) string {
|
||||
buff := make([]byte, len)
|
||||
_, _ = rand.Read(buff)
|
||||
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
|
||||
|
||||
// Base 64 can be longer than len
|
||||
return str[:len]
|
||||
}
|
||||
@@ -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,89 +0,0 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"github.com/elyby/chrly/api/mojang"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type uuidsStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
|
||||
args := m.Called(username)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error {
|
||||
m.Called(username, uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
type texturesStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *texturesStorageMock) 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 *texturesStorageMock) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
|
||||
m.Called(uuid, textures)
|
||||
}
|
||||
|
||||
func TestSplittedStorage(t *testing.T) {
|
||||
createMockedStorage := func() (*SplittedStorage, *uuidsStorageMock, *texturesStorageMock) {
|
||||
uuidsStorage := &uuidsStorageMock{}
|
||||
texturesStorage := &texturesStorageMock{}
|
||||
|
||||
return &SplittedStorage{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")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, "find me", result)
|
||||
uuidsMock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("StoreUuid", func(t *testing.T) {
|
||||
storage, uuidsMock, _ := createMockedStorage()
|
||||
uuidsMock.On("StoreUuid", "username", "result").Once()
|
||||
_ = storage.StoreUuid("username", "result")
|
||||
uuidsMock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("GetTextures", func(t *testing.T) {
|
||||
result := &mojang.SignedTexturesResponse{Id: "mock id"}
|
||||
storage, _, texturesMock := createMockedStorage()
|
||||
texturesMock.On("GetTextures", "uuid").Once().Return(result, nil)
|
||||
returned, err := storage.GetTextures("uuid")
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, result, returned)
|
||||
texturesMock.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("StoreTextures", func(t *testing.T) {
|
||||
toStore := &mojang.SignedTexturesResponse{}
|
||||
storage, _, texturesMock := createMockedStorage()
|
||||
texturesMock.On("StoreTextures", "mock id", toStore).Once()
|
||||
storage.StoreTextures("mock id", toStore)
|
||||
texturesMock.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValueNotFound_Error(t *testing.T) {
|
||||
err := &ValueNotFound{}
|
||||
assert.Equal(t, "value not found in the storage", err.Error())
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type TexturesProp struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Textures *TexturesResponse `json:"textures"`
|
||||
}
|
||||
|
||||
type TexturesResponse struct {
|
||||
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
|
||||
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CapeTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
|
||||
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *TexturesProp
|
||||
err = json.Unmarshal(jsonStr, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EncodeTextures(textures *TexturesProp) string {
|
||||
jsonSerialized, _ := json.Marshal(textures)
|
||||
return base64.URLEncoding.EncodeToString(jsonSerialized)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type texturesTestCase struct {
|
||||
Name string
|
||||
Encoded string
|
||||
Decoded *TexturesProp
|
||||
}
|
||||
|
||||
var texturesTestCases = []*texturesTestCase{
|
||||
{
|
||||
Name: "property without textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856010494),
|
||||
Textures: &TexturesResponse{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with classic skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856307412),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with alex skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856494791),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
|
||||
Metadata: &SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with skin and cape textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "d90b68bc81724329a047f1186dcd4336",
|
||||
ProfileName: "akronman1",
|
||||
Timestamp: int64(1555857675335),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
|
||||
},
|
||||
Cape: &CapeTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestDecodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("decode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures(testCase.Encoded)
|
||||
assert.Nil(err)
|
||||
assert.Equal(testCase.Decoded, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("invalid base64")
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
|
||||
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("encode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result := EncodeTextures(testCase.Decoded)
|
||||
assert.Equal(testCase.Encoded, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
82
auth/jwt.go
82
auth/jwt.go
@@ -1,82 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SermoDigital/jose/crypto"
|
||||
"github.com/SermoDigital/jose/jws"
|
||||
)
|
||||
|
||||
var hashAlg = crypto.SigningMethodHS256
|
||||
|
||||
const scopesClaim = "scopes"
|
||||
|
||||
type Scope string
|
||||
|
||||
var (
|
||||
SkinScope = Scope("skin")
|
||||
)
|
||||
|
||||
type JwtAuth struct {
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func (t *JwtAuth) NewToken(scopes ...Scope) ([]byte, error) {
|
||||
if len(t.Key) == 0 {
|
||||
return nil, errors.New("signing key not available")
|
||||
}
|
||||
|
||||
claims := jws.Claims{}
|
||||
claims.Set(scopesClaim, scopes)
|
||||
claims.SetIssuedAt(time.Now())
|
||||
encoder := jws.NewJWT(claims, hashAlg)
|
||||
token, err := encoder.Serialize(t.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (t *JwtAuth) Check(req *http.Request) error {
|
||||
if len(t.Key) == 0 {
|
||||
return &Unauthorized{"Signing key not set"}
|
||||
}
|
||||
|
||||
bearerToken := req.Header.Get("Authorization")
|
||||
if bearerToken == "" {
|
||||
return &Unauthorized{"Authentication header not presented"}
|
||||
}
|
||||
|
||||
if !strings.EqualFold(bearerToken[0:7], "BEARER ") {
|
||||
return &Unauthorized{"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"}
|
||||
}
|
||||
|
||||
err = token.Validate(t.Key, hashAlg)
|
||||
if err != nil {
|
||||
return &Unauthorized{"JWT token have invalid signature. It may be corrupted or expired."}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Unauthorized struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *Unauthorized) Error() string {
|
||||
if e.Reason != "" {
|
||||
return e.Reason
|
||||
}
|
||||
|
||||
return "Unauthorized"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
12
build/package/Dockerfile
Normal file
12
build/package/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
ARG BINARY
|
||||
|
||||
FROM scratch
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY ${BINARY} /usr/local/bin/chrly
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/chrly"]
|
||||
CMD ["serve"]
|
||||
16
cmd/chrly/chrly.go
Normal file
16
cmd/chrly/chrly.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
. "ely.by/chrly/internal/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := RootCmd.Execute()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
37
cmd/root.go
37
cmd/root.go
@@ -1,37 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/elyby/chrly/bootstrap"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "chrly",
|
||||
Short: "Implementation of Minecraft skins system server",
|
||||
Version: bootstrap.GetVersion(),
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
if err := RootCmd.Execute(); err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
viper.AutomaticEnv()
|
||||
replacer := strings.NewReplacer(".", "_")
|
||||
viper.SetEnvKeyReplacer(replacer)
|
||||
}
|
||||
89
cmd/serve.go
89
cmd/serve.go
@@ -1,89 +0,0 @@
|
||||
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",
|
||||
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))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
29
cmd/token.go
29
cmd/token.go
@@ -1,29 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/elyby/chrly/auth"
|
||||
|
||||
"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)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create new token. The error is %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s\n", token)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(tokenCmd)
|
||||
}
|
||||
2
data/capes/.gitignore
vendored
2
data/capes/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
data/redis/.gitignore
vendored
2
data/redis/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -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
|
||||
}
|
||||
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
|
||||
}
|
||||
16
deploy/docker/docker-compose.yml
Normal file
16
deploy/docker/docker-compose.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
version: '3'
|
||||
services:
|
||||
chrly:
|
||||
image: elyby/chrly:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
CHRLY_SECRET: replace_this_value_in_production
|
||||
STORAGE_REDIS_HOST: redis
|
||||
|
||||
redis:
|
||||
image: redis:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
@@ -1,14 +0,0 @@
|
||||
# This file can be used to start up necessary services.
|
||||
# Copy it into the docker-compose.yml:
|
||||
# > cp docker-compose.dev.yml docker-compose.yml
|
||||
# And then run it:
|
||||
# > docker-compose up -d
|
||||
|
||||
version: '2'
|
||||
services:
|
||||
redis:
|
||||
image: redis:4.0-32bit
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
@@ -1,27 +0,0 @@
|
||||
# This file can be used to run application in the production environment.
|
||||
# Copy it into the docker-compose.yml:
|
||||
# > cp docker-compose.prod.yml docker-compose.yml
|
||||
# And then run it:
|
||||
# > docker-compose up -d
|
||||
# Service will be listened at the http://localhost
|
||||
|
||||
version: '2'
|
||||
services:
|
||||
app:
|
||||
image: elyby/chrly
|
||||
hostname: chrly0
|
||||
restart: always
|
||||
links:
|
||||
- redis
|
||||
volumes:
|
||||
- ./data/capes:/data/capes
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
CHRLY_SECRET: replace_this_value_in_production
|
||||
|
||||
redis:
|
||||
image: redis:4.0-32bit # 32-bit version is recommended to spare some memory
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ ! -d /data/capes ]; then
|
||||
mkdir -p /data/capes
|
||||
fi
|
||||
|
||||
if [ "$1" = "serve" ] || [ "$1" = "token" ] || [ "$1" = "version" ]; then
|
||||
set -- /usr/local/bin/chrly "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
167
docs/swagger.yml
Normal file
167
docs/swagger.yml
Normal file
@@ -0,0 +1,167 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Chrly
|
||||
version: v5
|
||||
|
||||
servers:
|
||||
- url: http://skinsystem.ely.by
|
||||
description: Ely.by's production server
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
|
||||
responses:
|
||||
UnauthorizedError:
|
||||
description: Access token is missing or invalid
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: the token doesn't have the scope to perform the action
|
||||
|
||||
paths:
|
||||
/api/profiles:
|
||||
post:
|
||||
operationId: upsertProfile
|
||||
summary: Upsert player's profile.
|
||||
description: >
|
||||
Creates a new user profile or updates an existing one.
|
||||
The user is identified by their UUID.
|
||||
If several users with different UUIDs try to occupy the same username, the last one wins.
|
||||
tags:
|
||||
- profiles
|
||||
- api
|
||||
security:
|
||||
- BearerAuth: [profiles]
|
||||
requestBody:
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
required:
|
||||
- uuid
|
||||
- username
|
||||
properties:
|
||||
uuid:
|
||||
type: string
|
||||
example: 8cd2c16e-7ef3-4fa1-87ea-6e602bffd7c7
|
||||
username:
|
||||
type: string
|
||||
example: ErickSkrauch
|
||||
skinUrl:
|
||||
type: string
|
||||
example: https://example.com/skin.png
|
||||
skinModel:
|
||||
type: string
|
||||
enum:
|
||||
- steve
|
||||
- slim
|
||||
example: slim
|
||||
capeUrl:
|
||||
type: string
|
||||
example: https://example.com/cape.png
|
||||
mojangTextures:
|
||||
type: string
|
||||
example: eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0
|
||||
mojangSignature:
|
||||
description: Required when the `mojangTextures` parameter is present in the request.
|
||||
type: string
|
||||
example: QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=
|
||||
responses:
|
||||
201:
|
||||
description: The profiles has been successfully upserted.
|
||||
400:
|
||||
description: Some fields doesn't pass the validation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: object
|
||||
properties:
|
||||
body:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- The body of the request must be a valid url-encoded string
|
||||
username:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- Username is a required field
|
||||
- Username must be a valid username
|
||||
- Username must be a maximum of 21 in length
|
||||
uuid:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- Uuid is a required field
|
||||
- Uuid must be a valid UUID
|
||||
skinUrl:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- SkinUrl must be a valid URL
|
||||
skinModel:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- SkinModel must be a maximum of 20 in length
|
||||
capeUrl:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- CapeUrl must be a valid URL
|
||||
mojangTextures:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- MojangTextures must be a valid Base64 string
|
||||
mojangSignature:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- MojangSignature is a required field
|
||||
- MojangSignature must be a valid Base64 string
|
||||
401:
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
|
||||
/api/profiles/{uuid}:
|
||||
delete:
|
||||
operationId: deleteProfile
|
||||
summary: Deletes a player's profile by its UUID.
|
||||
description: Returns a successful response even if the profile did not previously exist.
|
||||
tags:
|
||||
- profiles
|
||||
- api
|
||||
parameters:
|
||||
- name: uuid
|
||||
in: query
|
||||
required: true
|
||||
description: The UUID can be passed with or without dashes, upper or lower cased.
|
||||
example: 8cd2c16e-7ef3-4fa1-87ea-6e602bffd7c7
|
||||
schema:
|
||||
type: string
|
||||
minimum: 500
|
||||
security:
|
||||
- BearerAuth: [ profiles ]
|
||||
responses:
|
||||
204:
|
||||
description: The profiles has been successfully deleted.
|
||||
401:
|
||||
$ref: "#/components/responses/UnauthorizedError"
|
||||
98
go.mod
Normal file
98
go.mod
Normal file
@@ -0,0 +1,98 @@
|
||||
module ely.by/chrly
|
||||
|
||||
go 1.21
|
||||
|
||||
// Main dependencies
|
||||
require (
|
||||
github.com/agoda-com/opentelemetry-go/otelslog v0.1.1
|
||||
github.com/agoda-com/opentelemetry-logs-go v0.4.3
|
||||
github.com/brunomvsouza/singleflight v0.4.0
|
||||
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/go-playground/validator/v10 v10.17.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/huandu/xstrings v1.4.0
|
||||
github.com/jellydator/ttlcache/v3 v3.1.1
|
||||
github.com/mediocregopher/radix/v4 v4.1.4
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/viper v1.18.1
|
||||
github.com/valyala/fastjson v1.6.4
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.49.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.49.0
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.49.0
|
||||
go.opentelemetry.io/otel v1.24.0
|
||||
go.opentelemetry.io/otel/metric v1.24.0
|
||||
go.opentelemetry.io/otel/sdk v1.24.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.24.0
|
||||
go.opentelemetry.io/otel/trace v1.24.0
|
||||
go.uber.org/multierr v1.11.0
|
||||
)
|
||||
|
||||
// Dev dependencies
|
||||
require (
|
||||
github.com/h2non/gock v1.2.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
)
|
||||
|
||||
// Indirect dependencies
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // 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/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // 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/prometheus/client_golang v1.18.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.45.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // 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.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.46.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sync v0.5.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014 // indirect
|
||||
google.golang.org/grpc v1.61.1 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
217
go.sum
Normal file
217
go.sum
Normal file
@@ -0,0 +1,217 @@
|
||||
github.com/agoda-com/opentelemetry-go/otelslog v0.1.1 h1:6nV8PZCzySHuh9kP/HZ2OJqGucwQiM+yZRugKDvtzj4=
|
||||
github.com/agoda-com/opentelemetry-go/otelslog v0.1.1/go.mod h1:CSc0veIcY/HsIfH7l5PGtIpRvBttk09QUQlweVkD2PI=
|
||||
github.com/agoda-com/opentelemetry-logs-go v0.4.3 h1:dYAx/q9di+/Pv6HuGq59DFIOjqKT0LTy3PYTIz8ccq8=
|
||||
github.com/agoda-com/opentelemetry-logs-go v0.4.3/go.mod h1:gPQ0fHqroxNP2DlQFZt29/pfqGiP2m6Q5CCxEgLo6yQ=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/brunomvsouza/singleflight v0.4.0 h1:9dNcTeYoXSus3xbZEM0EEZ11EcCRjUZOvVW8rnDMG5Y=
|
||||
github.com/brunomvsouza/singleflight v0.4.0/go.mod h1:8RYo9j5WQRupmsnUz5DlUWZxDLNi+t9Zhj3EZFmns7I=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
|
||||
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/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/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM=
|
||||
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/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
|
||||
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.1 h1:RCgYJqo3jgvhl+fEWvjNW8thxGWsgxi+TPhRir1Y9y8=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.1/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
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/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
|
||||
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
|
||||
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/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/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
|
||||
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
|
||||
github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
|
||||
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
|
||||
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
|
||||
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
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.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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI=
|
||||
github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.49.0 h1:SPuRs5SgCd9loXBBY5HuZsyuweowIs6ADg9UtStEv+k=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.49.0/go.mod h1:BDsrww+PTgwfvBjsZQMstsE1n5dS3hDCtAfYG1t3wag=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.48.0 h1:7rkdNoXgScpSUIqBch/VOB24fk9g0wl3Tr5WPtshi9o=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.48.0/go.mod h1:U3t9uswWhDzieXHMNWP6zk87J4HNondiibKMdNLpnMk=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.49.0 h1:h+c4WbSjBBc3j+IsxwB2mWvkm2nDh0SyGLa5Y5+V9cw=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.49.0/go.mod h1:FObmJ0epY1FcwMR7aq7sRkrCfwwV3d0GBGFfyV5JUBg=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0 h1:dJlCKeq+zmO5Og4kgxqPvvJrzuD/mygs1g/NYM9dAsU=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0/go.mod h1:p+hpBCpLHpuUrR0lHgnHbUnbCBll1IhrcMIlycC+xYs=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.49.0 h1:dg9y+7ArpumB6zwImJv47RHfdgOGQ1EMkzP5vLkEnTU=
|
||||
go.opentelemetry.io/contrib/instrumentation/runtime v0.49.0/go.mod h1:Ul4MtXqu/hJBM+v7a6dCF0nHwckPMLpIpLeCi4+zfdw=
|
||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.24.0 h1:f2jriWfOdldanBwS9jNBdeOKAQN7b4ugAMaNu1/1k9g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.24.0/go.mod h1:B+bcQI1yTY+N0vqMpoZbEN7+XU4tNM0DmUiOwebFJWI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.24.0 h1:mM8nKi6/iFQ0iqst80wDHU2ge198Ye/TfN0WBS5U24Y=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.24.0/go.mod h1:0PrIIzDteLSmNyxqcGYRL4mDIo8OTuBAOI/Bn1URxac=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.46.0 h1:I8WIFXR351FoLJYuloU4EgXbtNX2URfU/85pUPheIEQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.46.0/go.mod h1:ztwVUHe5DTR/1v7PeuGRnU5Bbd4QKYwApWmuutKsJSs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0 h1:JYE2HM7pZbOt5Jhk8ndWZTUWYOVift2cHjXVMkPdmdc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0/go.mod h1:yMb/8c6hVsnma0RpsBMNo0fEiQKeclawtgaIaOp2MLY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA=
|
||||
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
||||
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.24.0 h1:yyMQrPzF+k88/DbH7o4FMAs80puqd+9osbiBrJrz/w8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.24.0/go.mod h1:I6Y5FjH6rvEnTTAYQz3Mmv2kl6Ek5IIrmwTLqMrrOE0=
|
||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
|
||||
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
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/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
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/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
|
||||
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.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=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe h1:USL2DhxfgRchafRvt/wYyyQNzwgL7ZiURcozOE/Pkvo=
|
||||
google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 h1:x9PwdEgd11LgK+orcck69WVRo7DezSO4VUMPI4xpc8A=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014 h1:FSL3lRCkhaPFxqi0s9o+V4UI2WTzAVOvkgbd4kVV4Wg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014/go.mod h1:SaPjaZGWb0lPqs6Ittu0spdfrOArqji4ZdeP5IC/9N4=
|
||||
google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY=
|
||||
google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
259
http/api.go
259
http/api.go
@@ -1,259 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
//noinspection GoSnakeCaseUsage
|
||||
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
||||
|
||||
var regexUuidAny = regexp.MustCompile(UUID_ANY)
|
||||
|
||||
func init() {
|
||||
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
|
||||
if message == "" {
|
||||
message = "Skin uploading is temporary unavailable"
|
||||
}
|
||||
|
||||
return errors.New(message)
|
||||
})
|
||||
|
||||
// Add ability to validate any possible uuid form
|
||||
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
|
||||
str := value.(string)
|
||||
if !regexUuidAny.MatchString(str) {
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("The %s field must contain valid UUID", field)
|
||||
}
|
||||
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
|
||||
cfg.Logger.IncCounter("api.skins.post.request", 1)
|
||||
validationErrors := validatePostSkinRequest(req)
|
||||
if validationErrors != nil {
|
||||
cfg.Logger.IncCounter("api.skins.post.validation_failed", 1)
|
||||
apiBadRequest(resp, validationErrors)
|
||||
return
|
||||
}
|
||||
|
||||
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
|
||||
username := req.Form.Get("username")
|
||||
|
||||
record, err := findIdentity(cfg.SkinsRepo, identityId, username)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Error on requesting a skin from the repository: :err", wd.ErrParam(err))
|
||||
apiServerError(resp)
|
||||
return
|
||||
}
|
||||
|
||||
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
|
||||
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
|
||||
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
|
||||
|
||||
record.Uuid = req.Form.Get("uuid")
|
||||
record.SkinId = skinId
|
||||
record.Is1_8 = is18
|
||||
record.IsSlim = isSlim
|
||||
record.Url = req.Form.Get("url")
|
||||
record.MojangTextures = req.Form.Get("mojangTextures")
|
||||
record.MojangSignature = req.Form.Get("mojangSignature")
|
||||
|
||||
err = cfg.SkinsRepo.Save(record)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Unable to save record to the repository: :err", wd.ErrParam(err))
|
||||
apiServerError(resp)
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Logger.IncCounter("api.skins.post.success", 1)
|
||||
resp.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (cfg *Config) DeleteSkinByUserId(resp http.ResponseWriter, req *http.Request) {
|
||||
cfg.Logger.IncCounter("api.skins.delete.request", 1)
|
||||
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)
|
||||
}
|
||||
|
||||
func (cfg *Config) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Request) {
|
||||
cfg.Logger.IncCounter("api.skins.delete.request", 1)
|
||||
username := mux.Vars(req)["username"]
|
||||
skin, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
if err != nil {
|
||||
cfg.Logger.IncCounter("api.skins.delete.not_found", 1)
|
||||
apiNotFound(resp, "Cannot find record for requested username")
|
||||
return
|
||||
}
|
||||
|
||||
cfg.deleteSkin(skin, resp)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
cfg.Logger.Error("Cannot delete skin by error: :err", wd.ErrParam(err))
|
||||
apiServerError(resp)
|
||||
return
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
shouldAppendSkinRequiredError := false
|
||||
url := request.Form.Get("url")
|
||||
_, _, skinErr := request.FormFile("skin")
|
||||
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
|
||||
shouldAppendSkinRequiredError = true
|
||||
} else if skinErr == nil {
|
||||
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
|
||||
} else if url != "" {
|
||||
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
|
||||
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
|
||||
}
|
||||
|
||||
mojangTextures := request.Form.Get("mojangTextures")
|
||||
if mojangTextures != "" {
|
||||
validationRules["mojangSignature"] = []string{"required"}
|
||||
}
|
||||
|
||||
validator := govalidator.New(govalidator.Options{
|
||||
Request: request,
|
||||
Rules: validationRules,
|
||||
RequiredDefault: false,
|
||||
FormSize: maxMultipartMemory,
|
||||
})
|
||||
validationResults := validator.Validate()
|
||||
if shouldAppendSkinRequiredError {
|
||||
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
|
||||
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
|
||||
}
|
||||
|
||||
if len(validationResults) != 0 {
|
||||
return validationResults
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findIdentity(repo 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)
|
||||
}
|
||||
501
http/api_test.go
501
http/api_test.go
@@ -1,501 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/elyby/chrly/auth"
|
||||
"github.com/elyby/chrly/db"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfig_PostSkin(t *testing.T) {
|
||||
t.Run("Upload new identity with textures info", 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://example.com/skin.png"
|
||||
resultModel.MojangTextures = ""
|
||||
resultModel.MojangSignature = ""
|
||||
|
||||
form := url.Values{
|
||||
"identityId": {"1"},
|
||||
"username": {"mock_user"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}
|
||||
|
||||
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{
|
||||
"identityId": {"1"},
|
||||
"username": {"mock_user"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://textures-server.com/skin.png"},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(201, resp.StatusCode)
|
||||
response, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.Empty(response)
|
||||
})
|
||||
|
||||
t.Run("Keep the same uuid and username, but change identityId", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, mocks := setupMocks(ctrl)
|
||||
|
||||
resultModel := createSkinModel("mock_user", false)
|
||||
resultModel.UserId = 2
|
||||
resultModel.SkinId = 5
|
||||
resultModel.Url = "http://example.com/skin.png"
|
||||
resultModel.MojangTextures = ""
|
||||
resultModel.MojangSignature = ""
|
||||
|
||||
form := url.Values{
|
||||
"identityId": {"2"},
|
||||
"username": {"mock_user"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}
|
||||
|
||||
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{
|
||||
"identityId": {"1"},
|
||||
"username": {"changed_username"},
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"skinId": {"5"},
|
||||
"is1_8": {"0"},
|
||||
"isSlim": {"0"},
|
||||
"url": {"http://example.com/skin.png"},
|
||||
}
|
||||
|
||||
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(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))
|
||||
|
||||
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("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{
|
||||
"mojangTextures": {"someBase64EncodedString"},
|
||||
}
|
||||
|
||||
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.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": {
|
||||
"identityId": [
|
||||
"The identityId field is required",
|
||||
"The identityId field must be numeric",
|
||||
"The identityId field must be minimum 1 char"
|
||||
],
|
||||
"skinId": [
|
||||
"The skinId field is required",
|
||||
"The skinId field must be numeric",
|
||||
"The skinId field must be minimum 1 char"
|
||||
],
|
||||
"username": [
|
||||
"The username field is required"
|
||||
],
|
||||
"uuid": [
|
||||
"The uuid field is required",
|
||||
"The uuid field must contain valid UUID"
|
||||
],
|
||||
"url": [
|
||||
"One of url or skin should be provided, but not both"
|
||||
],
|
||||
"skin": [
|
||||
"One of url or skin should be provided, but not both"
|
||||
],
|
||||
"mojangSignature": [
|
||||
"The mojangSignature field is required"
|
||||
]
|
||||
}
|
||||
}`, string(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))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_DeleteSkinByUserId(t *testing.T) {
|
||||
t.Run("Delete skin by its identity id", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, mocks := setupMocks(ctrl)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/api/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)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(204, resp.StatusCode)
|
||||
response, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.Empty(response)
|
||||
})
|
||||
|
||||
t.Run("Try to remove not exists identity id", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, mocks := setupMocks(ctrl)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:2", 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)
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_DeleteSkinByUsername(t *testing.T) {
|
||||
t.Run("Delete skin by its identity username", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, mocks := setupMocks(ctrl)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_user", 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)
|
||||
|
||||
resp := w.Result()
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(204, resp.StatusCode)
|
||||
response, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.Empty(response)
|
||||
})
|
||||
|
||||
t.Run("Try to remove not exists identity username", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, mocks := setupMocks(ctrl)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_user_2", 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)
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
}
|
||||
|
||||
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
|
||||
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
|
||||
|
||||
func loadSkinFile() []byte {
|
||||
result := make([]byte, 92)
|
||||
_, err := base64.StdEncoding.Decode(result, OnePxPng)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
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
|
||||
}
|
||||
88
http/http.go
88
http/http.go
@@ -1,88 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mono83/slf/wd"
|
||||
|
||||
"github.com/elyby/chrly/interfaces"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenSpec string
|
||||
|
||||
SkinsRepo interfaces.SkinsRepository
|
||||
CapesRepo interfaces.CapesRepository
|
||||
MojangTexturesQueue interfaces.MojangTexturesQueue
|
||||
Logger wd.Watchdog
|
||||
Auth interfaces.AuthChecker
|
||||
}
|
||||
|
||||
func (cfg *Config) Run() error {
|
||||
cfg.Logger.Info(fmt.Sprintf("Starting, HTTP on: %s\n", cfg.ListenSpec))
|
||||
|
||||
listener, err := net.Listen("tcp", cfg.ListenSpec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16,
|
||||
Handler: cfg.CreateHandler(),
|
||||
}
|
||||
|
||||
go server.Serve(listener)
|
||||
|
||||
s := waitForSignal()
|
||||
cfg.Logger.Info(fmt.Sprintf("Got signal: %v, exiting.", s))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return <-ch
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestParseUsername(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
|
||||
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
37
internal/cmd/root.go
Normal file
37
internal/cmd/root.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
. "github.com/defval/di"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"ely.by/chrly/internal/di"
|
||||
"ely.by/chrly/internal/version"
|
||||
)
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "chrly",
|
||||
Short: "Implementation of the Minecraft skins system server",
|
||||
Version: version.Version(),
|
||||
}
|
||||
|
||||
func shouldGetContainer() *Container {
|
||||
container, err := di.New()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
viper.AutomaticEnv()
|
||||
replacer := strings.NewReplacer(".", "_")
|
||||
viper.SetEnvKeyReplacer(replacer)
|
||||
}
|
||||
55
internal/cmd/root_profiling.go
Normal file
55
internal/cmd/root_profiling.go
Normal file
@@ -0,0 +1,55 @@
|
||||
//go: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)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
internal/cmd/serve.go
Normal file
63
internal/cmd/serve.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"ely.by/chrly/internal/di"
|
||||
"ely.by/chrly/internal/http"
|
||||
"ely.by/chrly/internal/otel"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Starts HTTP handler for the skins system",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return startServer(di.ModuleSkinsystem, di.ModuleProfiles)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func startServer(modules ...string) error {
|
||||
container := shouldGetContainer()
|
||||
|
||||
var globalCtx context.Context
|
||||
err := container.Resolve(&globalCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var config *viper.Viper
|
||||
err = container.Resolve(&config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !config.GetBool("otel.sdk.disabled") {
|
||||
shutdownOtel, err := otel.SetupOTelSDK(globalCtx)
|
||||
defer func() {
|
||||
err := shutdownOtel(context.Background())
|
||||
if err != nil {
|
||||
slog.Error("Unable to shutdown OpenTelemetry", slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
config.Set("modules", modules)
|
||||
|
||||
err = container.Invoke(http.StartServer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
42
internal/cmd/token.go
Normal file
42
internal/cmd/token.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ely.by/chrly/internal/security"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tokenCmd = &cobra.Command{
|
||||
Use: "token scope1 ...",
|
||||
Example: "token profiles sign",
|
||||
Short: "Creates a new token, which allows to interact with Chrly API",
|
||||
ValidArgs: []string{string(security.ProfilesScope), string(security.SignScope)},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
container := shouldGetContainer()
|
||||
var auth *security.Jwt
|
||||
err := container.Resolve(&auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scopes := make([]security.Scope, len(args))
|
||||
for i := range args {
|
||||
scopes[i] = security.Scope(args[i])
|
||||
}
|
||||
|
||||
token, err := auth.NewToken(scopes...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to create a new token. The error is %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println(token)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(tokenCmd)
|
||||
}
|
||||
@@ -2,19 +2,28 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/elyby/chrly/bootstrap"
|
||||
"runtime"
|
||||
|
||||
"ely.by/chrly/internal/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)
|
||||
},
|
||||
}
|
||||
|
||||
18
internal/db/model.go
Normal file
18
internal/db/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package db
|
||||
|
||||
type Profile struct {
|
||||
// Uuid contains user's UUID without dashes in lower case
|
||||
Uuid string
|
||||
// Username contains user's username with the original casing
|
||||
Username string
|
||||
// SkinUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a skin
|
||||
SkinUrl string
|
||||
// SkinModel contains skin's model. It will be empty when the model is default
|
||||
SkinModel string
|
||||
// CapeUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a cape
|
||||
CapeUrl string
|
||||
// MojangTextures contains the original textures value from Mojang's skinsystem
|
||||
MojangTextures string
|
||||
// MojangSignature contains the original textures signature from Mojang's skinsystem
|
||||
MojangSignature string
|
||||
}
|
||||
205
internal/db/redis/redis.go
Normal file
205
internal/db/redis/redis.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mediocregopher/radix/v4"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
)
|
||||
|
||||
const usernameToProfileKey = "hash:username-to-profile"
|
||||
const userUuidToUsernameKey = "hash:uuid-to-username"
|
||||
|
||||
type Redis struct {
|
||||
client radix.Client
|
||||
serializer db.ProfileSerializer
|
||||
}
|
||||
|
||||
func New(ctx context.Context, profileSerializer db.ProfileSerializer, 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,
|
||||
serializer: profileSerializer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Redis) FindProfileByUsername(ctx context.Context, username string) (*db.Profile, error) {
|
||||
var profile *db.Profile
|
||||
err := r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
profile, err = r.findProfileByUsername(ctx, conn, username)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return profile, err
|
||||
}
|
||||
|
||||
func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) {
|
||||
var encodedResult []byte
|
||||
err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(encodedResult) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return r.serializer.Deserialize(encodedResult)
|
||||
}
|
||||
|
||||
func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) {
|
||||
var username string
|
||||
return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid)))
|
||||
}
|
||||
|
||||
func (r *Redis) SaveProfile(ctx context.Context, profile *db.Profile) error {
|
||||
return r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return r.saveProfile(ctx, conn, profile)
|
||||
}))
|
||||
}
|
||||
|
||||
func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error {
|
||||
newUsernameHashKey := usernameHashKey(profile.Username)
|
||||
existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serializedProfile, err := r.serializer.Serialize(profile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Redis) RemoveProfileByUuid(ctx context.Context, uuid string) error {
|
||||
return r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
return r.removeProfileByUuid(ctx, conn, uuid)
|
||||
}))
|
||||
}
|
||||
|
||||
func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error {
|
||||
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
|
||||
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", userUuidToUsernameKey, normalizeUuid(uuid)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
|
||||
}
|
||||
|
||||
func (r *Redis) GetUuidForMojangUsername(ctx context.Context, username string) (string, string, error) {
|
||||
var uuid string
|
||||
foundUsername := username
|
||||
err := r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
|
||||
var err error
|
||||
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
|
||||
|
||||
return err
|
||||
}))
|
||||
|
||||
return uuid, foundUsername, err
|
||||
}
|
||||
|
||||
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
|
||||
key := buildMojangUsernameKey(username)
|
||||
var result string
|
||||
err := conn.Do(ctx, radix.Cmd(&result, "GET", key))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if result == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(result, ":")
|
||||
|
||||
return parts[1], parts[0], nil
|
||||
}
|
||||
|
||||
func (r *Redis) StoreMojangUuid(ctx context.Context, username string, uuid string) error {
|
||||
return r.client.Do(ctx, 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 := fmt.Sprintf("%s:%s", username, uuid)
|
||||
err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Redis) Ping(ctx context.Context) error {
|
||||
return r.client.Do(ctx, radix.Cmd(nil, "PING"))
|
||||
}
|
||||
|
||||
func normalizeUuid(uuid string) string {
|
||||
return strings.ToLower(strings.ReplaceAll(uuid, "-", ""))
|
||||
}
|
||||
|
||||
func usernameHashKey(username string) string {
|
||||
return strings.ToLower(username)
|
||||
}
|
||||
|
||||
func buildMojangUsernameKey(username string) string {
|
||||
return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username))
|
||||
}
|
||||
281
internal/db/redis/redis_integration_test.go
Normal file
281
internal/db/redis/redis_integration_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
//go:build redis
|
||||
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/mediocregopher/radix/v4"
|
||||
"github.com/stretchr/testify/mock"
|
||||
assert "github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
type MockProfileSerializer struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) {
|
||||
args := m.Called(profile)
|
||||
|
||||
return []byte(args.String(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) {
|
||||
args := m.Called(value)
|
||||
var result *db.Profile
|
||||
if casted, ok := args.Get(0).(*db.Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Run("should connect", func(t *testing.T) {
|
||||
conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12)
|
||||
assert.Nil(t, err)
|
||||
assert.NotNil(t, conn)
|
||||
})
|
||||
|
||||
t.Run("should return error", func(t *testing.T) {
|
||||
conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, conn)
|
||||
})
|
||||
}
|
||||
|
||||
type redisTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Redis *Redis
|
||||
Serializer *MockProfileSerializer
|
||||
|
||||
cmd func(cmd string, args ...interface{}) string
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) SetupSuite() {
|
||||
s.Serializer = &MockProfileSerializer{}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := New(ctx, s.Serializer, redisAddr, 10)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
|
||||
}
|
||||
|
||||
s.Redis = conn
|
||||
s.cmd = func(cmd string, args ...interface{}) string {
|
||||
var result string
|
||||
err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) SetupSubTest() {
|
||||
// Cleanup database before each test
|
||||
s.cmd("FLUSHALL")
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TearDownSubTest() {
|
||||
s.Serializer.AssertExpectations(s.T())
|
||||
for _, call := range s.Serializer.ExpectedCalls {
|
||||
call.Unset()
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedis(t *testing.T) {
|
||||
suite.Run(t, new(redisTestSuite))
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestFindProfileByUsername() {
|
||||
ctx := context.Background()
|
||||
s.Run("exists record", func() {
|
||||
serializedData := []byte("mock.exists.profile")
|
||||
expectedProfile := &db.Profile{}
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
|
||||
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
|
||||
|
||||
profile, err := s.Redis.FindProfileByUsername(ctx, "Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(expectedProfile, profile)
|
||||
})
|
||||
|
||||
s.Run("not exists record", func() {
|
||||
profile, err := s.Redis.FindProfileByUsername(ctx, "Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(profile)
|
||||
})
|
||||
|
||||
s.Run("an error from serializer implementation", func() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data")
|
||||
s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError)
|
||||
|
||||
profile, err := s.Redis.FindProfileByUsername(ctx, "Mock")
|
||||
s.Require().Nil(profile)
|
||||
s.Require().ErrorIs(err, expectedError)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestSaveProfile() {
|
||||
ctx := context.Background()
|
||||
|
||||
s.Run("save new entity", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
|
||||
Username: "Mock",
|
||||
}
|
||||
serializedProfile := "serialized-profile"
|
||||
s.Serializer.On("Serialize", profile).Return(serializedProfile, nil)
|
||||
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile)
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.SaveProfile(ctx, profile)
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Equal("mock", uuidResp)
|
||||
|
||||
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Equal(serializedProfile, profileResp)
|
||||
})
|
||||
|
||||
s.Run("update exists record with changed username", func() {
|
||||
newProfile := &db.Profile{
|
||||
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
|
||||
Username: "NewMock",
|
||||
}
|
||||
serializedNewProfile := "serialized-new-profile"
|
||||
s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil)
|
||||
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile")
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.SaveProfile(ctx, newProfile)
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Equal("newmock", uuidResp)
|
||||
|
||||
newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock")
|
||||
s.Require().Equal(serializedNewProfile, newProfileResp)
|
||||
|
||||
oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Empty(oldProfileResp)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestRemoveProfileByUuid() {
|
||||
ctx := context.Background()
|
||||
|
||||
s.Run("exists record", func() {
|
||||
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile")
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.RemoveProfileByUuid(ctx, "f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Empty(uuidResp)
|
||||
|
||||
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
|
||||
s.Require().Empty(profileResp)
|
||||
})
|
||||
|
||||
s.Run("uuid exists, username is missing", func() {
|
||||
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
|
||||
|
||||
err := s.Redis.RemoveProfileByUuid(ctx, "f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
|
||||
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
|
||||
s.Require().Empty(uuidResp)
|
||||
})
|
||||
|
||||
s.Run("uuid not exists", func() {
|
||||
err := s.Redis.RemoveProfileByUuid(ctx, "f57f36d5-4f50-4728-948a-42d5d80b18f3")
|
||||
s.Require().NoError(err)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestGetUuidForMojangUsername() {
|
||||
ctx := context.Background()
|
||||
|
||||
s.Run("exists record", func() {
|
||||
s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
|
||||
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername(ctx, "Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("MoCk", username)
|
||||
s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
|
||||
})
|
||||
|
||||
s.Run("exists record with empty uuid value", func() {
|
||||
s.cmd("SET", "mojang:uuid:mock", "MoCk:")
|
||||
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername(ctx, "Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal("MoCk", username)
|
||||
s.Require().Empty(uuid)
|
||||
})
|
||||
|
||||
s.Run("not exists record", func() {
|
||||
uuid, username, err := s.Redis.GetUuidForMojangUsername(ctx, "Mock")
|
||||
s.Require().NoError(err)
|
||||
s.Require().Empty(username)
|
||||
s.Require().Empty(uuid)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestStoreUuid() {
|
||||
ctx := context.Background()
|
||||
|
||||
s.Run("store uuid", func() {
|
||||
err := s.Redis.StoreMojangUuid(ctx, "MoCk", "d3ca513eb3e14946b58047f2bd3530fd")
|
||||
s.Require().NoError(err)
|
||||
|
||||
resp := s.cmd("GET", "mojang:uuid:mock")
|
||||
s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
|
||||
})
|
||||
|
||||
s.Run("store empty uuid", func() {
|
||||
err := s.Redis.StoreMojangUuid(ctx, "MoCk", "")
|
||||
s.Require().NoError(err)
|
||||
|
||||
resp := s.cmd("GET", "mojang:uuid:mock")
|
||||
s.Require().Equal(resp, "MoCk:")
|
||||
})
|
||||
}
|
||||
|
||||
func (s *redisTestSuite) TestPing() {
|
||||
err := s.Redis.Ping(context.Background())
|
||||
s.Require().Nil(err)
|
||||
}
|
||||
136
internal/db/serializer.go
Normal file
136
internal/db/serializer.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
type ProfileSerializer interface {
|
||||
Serialize(profile *Profile) ([]byte, error)
|
||||
Deserialize(value []byte) (*Profile, error)
|
||||
}
|
||||
|
||||
func NewJsonSerializer() *JsonSerializer {
|
||||
return &JsonSerializer{
|
||||
parserPool: &fastjson.ParserPool{},
|
||||
}
|
||||
}
|
||||
|
||||
type JsonSerializer struct {
|
||||
parserPool *fastjson.ParserPool
|
||||
}
|
||||
|
||||
// Reasons for manual JSON serialization:
|
||||
// 1. The Profile must be pure and must not contain tags.
|
||||
// 2. Without tags it's impossible to apply omitempty during serialization.
|
||||
// 3. Without omitempty we significantly inflate the storage size, which is critical for large deployments.
|
||||
// Since the JSON structure in this case is very simple, it's very easy to write a manual serialization,
|
||||
// achieving all constraints above.
|
||||
func (s *JsonSerializer) Serialize(profile *Profile) ([]byte, error) {
|
||||
var builder strings.Builder
|
||||
// Prepare for the worst case (e.g. long username, long textures links, long Mojang textures and signature)
|
||||
// to prevent additional memory allocations during serialization
|
||||
builder.Grow(1536)
|
||||
builder.WriteString(`{"uuid":"`)
|
||||
builder.WriteString(profile.Uuid)
|
||||
builder.WriteString(`","username":"`)
|
||||
builder.WriteString(profile.Username)
|
||||
builder.WriteString(`"`)
|
||||
if profile.SkinUrl != "" {
|
||||
builder.WriteString(`,"skinUrl":"`)
|
||||
builder.WriteString(profile.SkinUrl)
|
||||
builder.WriteString(`"`)
|
||||
if profile.SkinModel != "" {
|
||||
builder.WriteString(`,"skinModel":"`)
|
||||
builder.WriteString(profile.SkinModel)
|
||||
builder.WriteString(`"`)
|
||||
}
|
||||
}
|
||||
|
||||
if profile.CapeUrl != "" {
|
||||
builder.WriteString(`,"capeUrl":"`)
|
||||
builder.WriteString(profile.CapeUrl)
|
||||
builder.WriteString(`"`)
|
||||
}
|
||||
|
||||
if profile.MojangTextures != "" {
|
||||
builder.WriteString(`,"mojangTextures":"`)
|
||||
builder.WriteString(profile.MojangTextures)
|
||||
builder.WriteString(`","mojangSignature":"`)
|
||||
builder.WriteString(profile.MojangSignature)
|
||||
builder.WriteString(`"`)
|
||||
}
|
||||
|
||||
builder.WriteString("}")
|
||||
|
||||
return []byte(builder.String()), nil
|
||||
}
|
||||
|
||||
func (s *JsonSerializer) Deserialize(value []byte) (*Profile, error) {
|
||||
parser := s.parserPool.Get()
|
||||
defer s.parserPool.Put(parser)
|
||||
v, err := parser.ParseBytes(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := &Profile{
|
||||
Uuid: string(v.GetStringBytes("uuid")),
|
||||
Username: string(v.GetStringBytes("username")),
|
||||
SkinUrl: string(v.GetStringBytes("skinUrl")),
|
||||
SkinModel: string(v.GetStringBytes("skinModel")),
|
||||
CapeUrl: string(v.GetStringBytes("capeUrl")),
|
||||
MojangTextures: string(v.GetStringBytes("mojangTextures")),
|
||||
MojangSignature: string(v.GetStringBytes("mojangSignature")),
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func NewZlibEncoder(serializer ProfileSerializer) *ZlibEncoder {
|
||||
return &ZlibEncoder{serializer}
|
||||
}
|
||||
|
||||
type ZlibEncoder struct {
|
||||
serializer ProfileSerializer
|
||||
}
|
||||
|
||||
func (s *ZlibEncoder) Serialize(profile *Profile) ([]byte, error) {
|
||||
serialized, err := s.serializer.Serialize(profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var buff bytes.Buffer
|
||||
writer := zlib.NewWriter(&buff)
|
||||
_, err = writer.Write(serialized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = writer.Close()
|
||||
|
||||
return buff.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *ZlibEncoder) Deserialize(value []byte) (*Profile, error) {
|
||||
buff := bytes.NewReader(value)
|
||||
reader, err := zlib.NewReader(buff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultBuffer := new(bytes.Buffer)
|
||||
_, err = io.Copy(resultBuffer, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = reader.Close()
|
||||
|
||||
return s.serializer.Deserialize(resultBuffer.Bytes())
|
||||
}
|
||||
194
internal/db/serializer_test.go
Normal file
194
internal/db/serializer_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJsonSerializer(t *testing.T) {
|
||||
var testCases = map[string]*struct {
|
||||
*Profile
|
||||
Serialized []byte
|
||||
Error error
|
||||
}{
|
||||
"full structure": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
|
||||
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`),
|
||||
},
|
||||
"default skin model": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png"}`),
|
||||
},
|
||||
"cape only": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","capeUrl":"https://example.com/cape.png"}`),
|
||||
},
|
||||
"minimal structure": {
|
||||
Profile: &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
},
|
||||
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username"}`),
|
||||
},
|
||||
"invalid json structure": {
|
||||
Serialized: []byte(`this is not json`),
|
||||
Error: errors.New(`cannot parse JSON: unexpected value found: "this is not json"; unparsed tail: "this is not json"`),
|
||||
},
|
||||
}
|
||||
|
||||
serializer := NewJsonSerializer()
|
||||
t.Run("Serialize", func(t *testing.T) {
|
||||
for n, c := range testCases {
|
||||
if c.Profile == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(n, func(t *testing.T) {
|
||||
result, err := serializer.Serialize(c.Profile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.Serialized, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Deserialize", func(t *testing.T) {
|
||||
for n, c := range testCases {
|
||||
t.Run(n, func(t *testing.T) {
|
||||
result, err := serializer.Deserialize(c.Serialized)
|
||||
require.Equal(t, c.Error, err)
|
||||
require.Equal(t, c.Profile, result)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type ProfileSerializerMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *ProfileSerializerMock) Serialize(profile *Profile) ([]byte, error) {
|
||||
args := m.Called(profile)
|
||||
var result []byte
|
||||
if casted, ok := args.Get(0).([]byte); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *ProfileSerializerMock) Deserialize(value []byte) (*Profile, error) {
|
||||
args := m.Called(value)
|
||||
var result *Profile
|
||||
if casted, ok := args.Get(0).(*Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
func TestZlibEncoder(t *testing.T) {
|
||||
profile := &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
}
|
||||
|
||||
t.Run("Serialize", func(t *testing.T) {
|
||||
t.Run("successfully", func(t *testing.T) {
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Serialize", profile).Return([]byte("serialized-string"), nil)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Serialize(profile)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}, result)
|
||||
})
|
||||
|
||||
t.Run("handle error from serializer", func(t *testing.T) {
|
||||
expectedError := errors.New("mock error")
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Serialize", profile).Return(nil, expectedError)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Serialize(profile)
|
||||
require.Same(t, expectedError, err)
|
||||
require.Nil(t, result)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Deserialize", func(t *testing.T) {
|
||||
t.Run("successfully", func(t *testing.T) {
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Deserialize", []byte("serialized-string")).Return(profile, nil)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, profile, result)
|
||||
})
|
||||
|
||||
t.Run("handle an error from deserializer", func(t *testing.T) {
|
||||
expectedError := errors.New("mock error")
|
||||
|
||||
serializer := &ProfileSerializerMock{}
|
||||
serializer.On("Deserialize", []byte("serialized-string")).Return(nil, expectedError)
|
||||
encoder := NewZlibEncoder(serializer)
|
||||
|
||||
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
|
||||
require.Same(t, expectedError, err)
|
||||
require.Nil(t, result)
|
||||
})
|
||||
|
||||
t.Run("handle invalid zlib encoding", func(t *testing.T) {
|
||||
encoder := NewZlibEncoder(&ProfileSerializerMock{})
|
||||
|
||||
result, err := encoder.Deserialize([]byte{0x6d, 0x6f, 0x63, 0x6b})
|
||||
require.ErrorContains(t, err, "invalid")
|
||||
require.Nil(t, result)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkFastJsonSerializer(b *testing.B) {
|
||||
profile := &Profile{
|
||||
Uuid: "f57f36d54f504728948a42d5d80b18f3",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
|
||||
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
|
||||
}
|
||||
serializedProfile := []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`)
|
||||
|
||||
serializer := NewJsonSerializer()
|
||||
b.Run("Serialize", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = serializer.Serialize(profile)
|
||||
}
|
||||
})
|
||||
|
||||
b.Run("Deserialize", func(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = serializer.Deserialize(serializedProfile)
|
||||
}
|
||||
})
|
||||
}
|
||||
10
internal/di/config.go
Normal file
10
internal/di/config.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"github.com/defval/di"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var configDiOptions = di.Options(
|
||||
di.Provide(viper.GetViper),
|
||||
)
|
||||
21
internal/di/context.go
Normal file
21
internal/di/context.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/defval/di"
|
||||
)
|
||||
|
||||
var contextDiOptions = di.Options(
|
||||
di.Provide(newBaseContext),
|
||||
)
|
||||
|
||||
func newBaseContext() context.Context {
|
||||
ctx := context.Background()
|
||||
ctx, _ = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM, os.Kill)
|
||||
|
||||
return ctx
|
||||
}
|
||||
52
internal/di/db.go
Normal file
52
internal/di/db.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/defval/di"
|
||||
"github.com/etherlabsio/healthcheck/v2"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
"ely.by/chrly/internal/db/redis"
|
||||
"ely.by/chrly/internal/mojang"
|
||||
"ely.by/chrly/internal/profiles"
|
||||
)
|
||||
|
||||
// Since there are no options for selecting target backends,
|
||||
// all constants in this case point to static specific implementations.
|
||||
var dbDiOptions = di.Options(
|
||||
di.Provide(newRedis,
|
||||
di.As(new(profiles.ProfilesRepository)),
|
||||
di.As(new(profiles.ProfilesFinder)),
|
||||
di.As(new(mojang.MojangUuidsStorage)),
|
||||
),
|
||||
)
|
||||
|
||||
func newRedis(container *di.Container, ctx context.Context, 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(
|
||||
ctx,
|
||||
db.NewZlibEncoder(&db.JsonSerializer{}),
|
||||
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: healthcheck.CheckerFunc(conn.Ping),
|
||||
}
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
17
internal/di/di.go
Normal file
17
internal/di/di.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package di
|
||||
|
||||
import "github.com/defval/di"
|
||||
|
||||
func New() (*di.Container, error) {
|
||||
return di.New(
|
||||
configDiOptions,
|
||||
contextDiOptions,
|
||||
dbDiOptions,
|
||||
handlersDiOptions,
|
||||
httpClientDiOptions,
|
||||
loggerDiOptions,
|
||||
mojangDiOptions,
|
||||
profilesDiOptions,
|
||||
serverDiOptions,
|
||||
)
|
||||
}
|
||||
125
internal/di/handlers.go
Normal file
125
internal/di/handlers.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/defval/di"
|
||||
"github.com/etherlabsio/healthcheck/v2"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/spf13/viper"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
|
||||
|
||||
. "ely.by/chrly/internal/http"
|
||||
"ely.by/chrly/internal/security"
|
||||
)
|
||||
|
||||
const ModuleSkinsystem = "skinsystem"
|
||||
const ModuleProfiles = "profiles"
|
||||
|
||||
var handlersDiOptions = di.Options(
|
||||
di.Provide(newHandlerFactory, di.As(new(http.Handler))),
|
||||
di.Provide(newSkinsystemHandler, di.WithName(ModuleSkinsystem)),
|
||||
di.Provide(newProfilesApiHandler, di.WithName(ModuleProfiles)),
|
||||
)
|
||||
|
||||
func newHandlerFactory(
|
||||
container *di.Container,
|
||||
config *viper.Viper,
|
||||
) (*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 slices.Contains(enabledModules, ModuleSkinsystem) {
|
||||
if err := container.Resolve(&router, di.Name(ModuleSkinsystem)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
router = mux.NewRouter()
|
||||
}
|
||||
|
||||
router.StrictSlash(true)
|
||||
router.Use(otelmux.Middleware("chrly"))
|
||||
router.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
|
||||
|
||||
if slices.Contains(enabledModules, ModuleProfiles) {
|
||||
var profilesApiRouter *mux.Router
|
||||
if err := container.Resolve(&profilesApiRouter, di.Name(ModuleProfiles)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var authenticator Authenticator
|
||||
if err := container.Resolve(&authenticator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profilesApiRouter.Use(NewAuthenticationMiddleware(authenticator, security.ProfilesScope))
|
||||
|
||||
mount(router, "/api/profiles", profilesApiRouter)
|
||||
}
|
||||
|
||||
// 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,
|
||||
profilesProvider ProfilesProvider,
|
||||
) (*mux.Router, error) {
|
||||
config.SetDefault("textures.extra_param_name", "chrly")
|
||||
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
|
||||
|
||||
skinsystem, err := NewSkinsystemApi(
|
||||
profilesProvider,
|
||||
config.GetString("textures.extra_param_name"),
|
||||
config.GetString("textures.extra_param_value"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return skinsystem.Handler(), nil
|
||||
}
|
||||
|
||||
func newProfilesApiHandler(profilesManager ProfilesManager) (*mux.Router, error) {
|
||||
profilesApi, err := NewProfilesApi(profilesManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return profilesApi.Handler(), nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
15
internal/di/httpClient.go
Normal file
15
internal/di/httpClient.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/defval/di"
|
||||
)
|
||||
|
||||
var httpClientDiOptions = di.Options(
|
||||
di.Provide(newHttpClient),
|
||||
)
|
||||
|
||||
func newHttpClient() *http.Client {
|
||||
return &http.Client{}
|
||||
}
|
||||
33
internal/di/logger.go
Normal file
33
internal/di/logger.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"github.com/defval/di"
|
||||
"github.com/getsentry/raven-go"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"ely.by/chrly/internal/version"
|
||||
)
|
||||
|
||||
var loggerDiOptions = di.Options(
|
||||
di.Provide(newSentry),
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
100
internal/di/mojang.go
Normal file
100
internal/di/mojang.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/defval/di"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"ely.by/chrly/internal/mojang"
|
||||
"ely.by/chrly/internal/profiles"
|
||||
)
|
||||
|
||||
var mojangDiOptions = di.Options(
|
||||
di.Provide(newMojangApi),
|
||||
di.Provide(newMojangTexturesProviderFactory),
|
||||
di.Provide(newMojangTexturesProvider),
|
||||
di.Provide(newMojangTexturesUuidsProviderFactory),
|
||||
di.Provide(newMojangTexturesBatchUUIDsProvider),
|
||||
di.Provide(newMojangSignedTexturesProvider),
|
||||
)
|
||||
|
||||
func newMojangApi(config *viper.Viper, httpClient *http.Client) (*mojang.MojangApi, error) {
|
||||
batchUuidsUrl := config.GetString("mojang.batch_uuids_url")
|
||||
if batchUuidsUrl != "" {
|
||||
if _, err := url.ParseRequestURI(batchUuidsUrl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
profileUrl := config.GetString("mojang.profile_url")
|
||||
if profileUrl != "" {
|
||||
if _, err := url.ParseRequestURI(batchUuidsUrl); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return mojang.NewMojangApi(httpClient, batchUuidsUrl, profileUrl), nil
|
||||
}
|
||||
|
||||
func newMojangTexturesProviderFactory(
|
||||
container *di.Container,
|
||||
config *viper.Viper,
|
||||
) (profiles.MojangProfilesProvider, error) {
|
||||
config.SetDefault("mojang_textures.enabled", true)
|
||||
if !config.GetBool("mojang_textures.enabled") {
|
||||
return &mojang.NilProvider{}, nil
|
||||
}
|
||||
|
||||
var provider *mojang.MojangTexturesProvider
|
||||
err := container.Resolve(&provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newMojangTexturesProvider(
|
||||
uuidsProvider mojang.UuidsProvider,
|
||||
texturesProvider mojang.TexturesProvider,
|
||||
) (*mojang.MojangTexturesProvider, error) {
|
||||
return mojang.NewMojangTexturesProvider(
|
||||
uuidsProvider,
|
||||
texturesProvider,
|
||||
)
|
||||
}
|
||||
|
||||
func newMojangTexturesUuidsProviderFactory(
|
||||
batchProvider *mojang.BatchUuidsProvider,
|
||||
uuidsStorage mojang.MojangUuidsStorage,
|
||||
) (mojang.UuidsProvider, error) {
|
||||
return mojang.NewUuidsProviderWithCache(batchProvider, uuidsStorage)
|
||||
}
|
||||
|
||||
func newMojangTexturesBatchUUIDsProvider(
|
||||
mojangApi *mojang.MojangApi,
|
||||
config *viper.Viper,
|
||||
) (*mojang.BatchUuidsProvider, error) {
|
||||
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
|
||||
config.SetDefault("queue.batch_size", 10)
|
||||
config.SetDefault("queue.strategy", "periodic")
|
||||
|
||||
return mojang.NewBatchUuidsProvider(
|
||||
mojangApi.UsernamesToUuids,
|
||||
config.GetInt("queue.batch_size"),
|
||||
config.GetDuration("queue.loop_delay"),
|
||||
config.GetString("queue.strategy") == "full-bus",
|
||||
)
|
||||
}
|
||||
|
||||
func newMojangSignedTexturesProvider(mojangApi *mojang.MojangApi) (mojang.TexturesProvider, error) {
|
||||
provider, err := mojang.NewMojangApiTexturesProvider(mojangApi.UuidToTextures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mojang.NewTexturesProviderWithInMemoryCache(provider)
|
||||
}
|
||||
27
internal/di/profiles.go
Normal file
27
internal/di/profiles.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"github.com/defval/di"
|
||||
|
||||
. "ely.by/chrly/internal/http"
|
||||
"ely.by/chrly/internal/profiles"
|
||||
)
|
||||
|
||||
var profilesDiOptions = di.Options(
|
||||
di.Provide(newProfilesManager, di.As(new(ProfilesManager))),
|
||||
di.Provide(newProfilesProvider, di.As(new(ProfilesProvider))),
|
||||
)
|
||||
|
||||
func newProfilesManager(r profiles.ProfilesRepository) *profiles.Manager {
|
||||
return profiles.NewManager(r)
|
||||
}
|
||||
|
||||
func newProfilesProvider(
|
||||
finder profiles.ProfilesFinder,
|
||||
mojangProfilesProvider profiles.MojangProfilesProvider,
|
||||
) (*profiles.Provider, error) {
|
||||
return profiles.NewProvider(
|
||||
finder,
|
||||
mojangProfilesProvider,
|
||||
)
|
||||
}
|
||||
57
internal/di/server.go
Normal file
57
internal/di/server.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package di
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/defval/di"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
. "ely.by/chrly/internal/http"
|
||||
"ely.by/chrly/internal/security"
|
||||
)
|
||||
|
||||
var serverDiOptions = di.Options(
|
||||
di.Provide(newAuthenticator, di.As(new(Authenticator))),
|
||||
di.Provide(newServer),
|
||||
)
|
||||
|
||||
func newAuthenticator(config *viper.Viper) (*security.Jwt, error) {
|
||||
key := config.GetString("chrly.secret")
|
||||
if key == "" {
|
||||
return nil, errors.New("chrly.secret must be set in order to use authenticator")
|
||||
}
|
||||
|
||||
return security.NewJwt([]byte(key)), nil
|
||||
}
|
||||
|
||||
func newServer(Config *viper.Viper, Handler http.Handler) *http.Server {
|
||||
Config.SetDefault("server.host", "")
|
||||
Config.SetDefault("server.port", 80)
|
||||
|
||||
var handler http.Handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
debug.PrintStack()
|
||||
request.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
|
||||
Handler.ServeHTTP(request, response)
|
||||
})
|
||||
|
||||
address := fmt.Sprintf("%s:%d", Config.GetString("server.host"), 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
|
||||
}
|
||||
109
internal/http/http.go
Normal file
109
internal/http/http.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"ely.by/chrly/internal/security"
|
||||
)
|
||||
|
||||
func StartServer(ctx context.Context, server *http.Server) {
|
||||
srvErr := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("Starting the server", slog.String("addr", server.Addr))
|
||||
srvErr <- server.ListenAndServe()
|
||||
close(srvErr)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-srvErr:
|
||||
slog.Error("Error in the server", slog.Any("error", err))
|
||||
case <-ctx.Done():
|
||||
slog.Info("Got stop signal, starting graceful shutdown")
|
||||
|
||||
stopCtx, cancelFunc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancelFunc()
|
||||
|
||||
_ = server.Shutdown(stopCtx)
|
||||
|
||||
slog.Info("Graceful shutdown succeed, exiting")
|
||||
}
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(req *http.Request, scope security.Scope) error
|
||||
}
|
||||
|
||||
func NewAuthenticationMiddleware(authenticator Authenticator, scope security.Scope) mux.MiddlewareFunc {
|
||||
return func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
err := authenticator.Authenticate(req, scope)
|
||||
if err != nil {
|
||||
apiForbidden(resp, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
handler.ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func NewConditionalMiddleware(cond func(req *http.Request) bool, m mux.MiddlewareFunc) mux.MiddlewareFunc {
|
||||
return func(handler http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
if cond(req) {
|
||||
handler = m.Middleware(handler)
|
||||
}
|
||||
|
||||
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]any{
|
||||
"errors": errorsPerField,
|
||||
})
|
||||
_, _ = resp.Write(result)
|
||||
}
|
||||
|
||||
var internalServerError = []byte("Internal server error")
|
||||
|
||||
func apiServerError(resp http.ResponseWriter, req *http.Request, err error) {
|
||||
span := trace.SpanFromContext(req.Context())
|
||||
span.SetStatus(codes.Error, "")
|
||||
span.RecordError(err)
|
||||
|
||||
resp.WriteHeader(http.StatusInternalServerError)
|
||||
resp.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = resp.Write(internalServerError)
|
||||
}
|
||||
|
||||
func apiForbidden(resp http.ResponseWriter, reason string) {
|
||||
resp.WriteHeader(http.StatusForbidden)
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
result, _ := json.Marshal(map[string]any{
|
||||
"error": reason,
|
||||
})
|
||||
_, _ = resp.Write(result)
|
||||
}
|
||||
129
internal/http/http_test.go
Normal file
129
internal/http/http_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
testify "github.com/stretchr/testify/require"
|
||||
|
||||
"ely.by/chrly/internal/security"
|
||||
)
|
||||
|
||||
type authCheckerMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *authCheckerMock) Authenticate(req *http.Request, scope security.Scope) error {
|
||||
return m.Called(req, scope).Error(0)
|
||||
}
|
||||
|
||||
func TestAuthenticationMiddleware(t *testing.T) {
|
||||
t.Run("pass", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
auth := &authCheckerMock{}
|
||||
auth.On("Authenticate", req, security.Scope("mock")).Once().Return(nil)
|
||||
|
||||
isHandlerCalled := false
|
||||
middlewareFunc := NewAuthenticationMiddleware(auth, "mock")
|
||||
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", "https://example.com", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
auth := &authCheckerMock{}
|
||||
auth.On("Authenticate", req, security.Scope("mock")).Once().Return(errors.New("error reason"))
|
||||
|
||||
isHandlerCalled := false
|
||||
middlewareFunc := NewAuthenticationMiddleware(auth, "mock")
|
||||
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, _ := io.ReadAll(resp.Body)
|
||||
testify.JSONEq(t, `{
|
||||
"error": "error reason"
|
||||
}`, string(body))
|
||||
|
||||
auth.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConditionalMiddleware(t *testing.T) {
|
||||
t.Run("true", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
isNestedMiddlewareCalled := false
|
||||
isHandlerCalled := false
|
||||
NewConditionalMiddleware(
|
||||
func(req *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
func(handler http.Handler) http.Handler {
|
||||
isNestedMiddlewareCalled = true
|
||||
return handler
|
||||
},
|
||||
).Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
isHandlerCalled = true
|
||||
})).ServeHTTP(resp, req)
|
||||
|
||||
testify.True(t, isNestedMiddlewareCalled, "Nested middleware wasn't called")
|
||||
testify.True(t, isHandlerCalled, "Handler wasn't called from the middleware")
|
||||
})
|
||||
|
||||
t.Run("false", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "https://example.com", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
isNestedMiddlewareCalled := false
|
||||
isHandlerCalled := false
|
||||
NewConditionalMiddleware(
|
||||
func(req *http.Request) bool {
|
||||
return false
|
||||
},
|
||||
func(handler http.Handler) http.Handler {
|
||||
isNestedMiddlewareCalled = true
|
||||
return handler
|
||||
},
|
||||
).Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
|
||||
isHandlerCalled = true
|
||||
})).ServeHTTP(resp, req)
|
||||
|
||||
testify.False(t, isNestedMiddlewareCalled, "Nested middleware shouldn't be called")
|
||||
testify.True(t, isHandlerCalled, "Handler wasn't called from the middleware")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotFoundHandler(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "https://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, _ := io.ReadAll(resp.Body)
|
||||
assert.JSONEq(`{
|
||||
"status": "404",
|
||||
"message": "Not Found"
|
||||
}`, string(response))
|
||||
}
|
||||
124
internal/http/profiles.go
Normal file
124
internal/http/profiles.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/huandu/xstrings"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
"ely.by/chrly/internal/otel"
|
||||
"ely.by/chrly/internal/profiles"
|
||||
)
|
||||
|
||||
type ProfilesManager interface {
|
||||
PersistProfile(ctx context.Context, profile *db.Profile) error
|
||||
RemoveProfileByUuid(ctx context.Context, uuid string) error
|
||||
}
|
||||
|
||||
func NewProfilesApi(profilesManager ProfilesManager) (*ProfilesApi, error) {
|
||||
metrics, err := newProfilesApiMetrics(otel.GetMeter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ProfilesApi{
|
||||
ProfilesManager: profilesManager,
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ProfilesApi struct {
|
||||
ProfilesManager
|
||||
|
||||
metrics *profilesApiMetrics
|
||||
}
|
||||
|
||||
func (p *ProfilesApi) Handler() *mux.Router {
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
router.HandleFunc("/", p.postProfileHandler).Methods(http.MethodPost)
|
||||
router.HandleFunc("/{uuid}", p.deleteProfileByUuidHandler).Methods(http.MethodDelete)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func (p *ProfilesApi) postProfileHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
p.metrics.UploadProfileRequest.Add(req.Context(), 1)
|
||||
|
||||
err := req.ParseForm()
|
||||
if err != nil {
|
||||
apiBadRequest(resp, map[string][]string{
|
||||
"body": {"The body of the request must be a valid url-encoded string"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
profile := &db.Profile{
|
||||
Uuid: req.Form.Get("uuid"),
|
||||
Username: req.Form.Get("username"),
|
||||
SkinUrl: req.Form.Get("skinUrl"),
|
||||
SkinModel: req.Form.Get("skinModel"),
|
||||
CapeUrl: req.Form.Get("capeUrl"),
|
||||
MojangTextures: req.Form.Get("mojangTextures"),
|
||||
MojangSignature: req.Form.Get("mojangSignature"),
|
||||
}
|
||||
|
||||
err = p.PersistProfile(req.Context(), profile)
|
||||
if err != nil {
|
||||
var v *profiles.ValidationError
|
||||
if errors.As(err, &v) {
|
||||
// Manager returns ValidationError according to the struct fields names.
|
||||
// They are uppercased, but otherwise the same as the names in the API.
|
||||
// So to make them consistent it's enough just to make the first lowercased.
|
||||
newErrors := make(map[string][]string, len(v.Errors))
|
||||
for field, errors := range v.Errors {
|
||||
newErrors[xstrings.FirstRuneToLower(field)] = errors
|
||||
}
|
||||
|
||||
apiBadRequest(resp, newErrors)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
apiServerError(resp, req, fmt.Errorf("unable to save profile to db: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (p *ProfilesApi) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
p.metrics.DeleteProfileRequest.Add(req.Context(), 1)
|
||||
|
||||
uuid := mux.Vars(req)["uuid"]
|
||||
err := p.ProfilesManager.RemoveProfileByUuid(req.Context(), uuid)
|
||||
if err != nil {
|
||||
apiServerError(resp, req, fmt.Errorf("unable to delete profile from db: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func newProfilesApiMetrics(meter metric.Meter) (*profilesApiMetrics, error) {
|
||||
m := &profilesApiMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.UploadProfileRequest, err = meter.Int64Counter("chrly.app.profiles.upload.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.DeleteProfileRequest, err = meter.Int64Counter("chrly.app.profiles.delete.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type profilesApiMetrics struct {
|
||||
UploadProfileRequest metric.Int64Counter
|
||||
DeleteProfileRequest metric.Int64Counter
|
||||
}
|
||||
171
internal/http/profiles_test.go
Normal file
171
internal/http/profiles_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
"ely.by/chrly/internal/profiles"
|
||||
)
|
||||
|
||||
type ProfilesManagerMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *ProfilesManagerMock) PersistProfile(ctx context.Context, profile *db.Profile) error {
|
||||
return m.Called(ctx, profile).Error(0)
|
||||
}
|
||||
|
||||
func (m *ProfilesManagerMock) RemoveProfileByUuid(ctx context.Context, uuid string) error {
|
||||
return m.Called(ctx, uuid).Error(0)
|
||||
}
|
||||
|
||||
type ProfilesTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
App *ProfilesApi
|
||||
|
||||
ProfilesManager *ProfilesManagerMock
|
||||
}
|
||||
|
||||
func (t *ProfilesTestSuite) SetupSubTest() {
|
||||
t.ProfilesManager = &ProfilesManagerMock{}
|
||||
t.App, _ = NewProfilesApi(t.ProfilesManager)
|
||||
}
|
||||
|
||||
func (t *ProfilesTestSuite) TearDownSubTest() {
|
||||
t.ProfilesManager.AssertExpectations(t.T())
|
||||
}
|
||||
|
||||
func (t *ProfilesTestSuite) TestPostProfile() {
|
||||
t.Run("successfully post profile", func() {
|
||||
t.ProfilesManager.On("PersistProfile", mock.Anything, &db.Profile{
|
||||
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
|
||||
Username: "mock_username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
MojangTextures: "bW9jawo=",
|
||||
MojangSignature: "bW9jawo=",
|
||||
}).Once().Return(nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "http://chrly/", bytes.NewBufferString(url.Values{
|
||||
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
|
||||
"username": {"mock_username"},
|
||||
"skinUrl": {"https://example.com/skin.png"},
|
||||
"skinModel": {"slim"},
|
||||
"capeUrl": {"https://example.com/cape.png"},
|
||||
"mojangTextures": {"bW9jawo="},
|
||||
"mojangSignature": {"bW9jawo="},
|
||||
}.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
t.Equal(http.StatusCreated, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("handle malformed body", func() {
|
||||
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("invalid;=url?encoded_string"))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
t.Equal(http.StatusBadRequest, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"errors": {
|
||||
"body": [
|
||||
"The body of the request must be a valid url-encoded string"
|
||||
]
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("receive validation errors", func() {
|
||||
t.ProfilesManager.On("PersistProfile", mock.Anything, mock.Anything).Once().Return(&profiles.ValidationError{
|
||||
Errors: map[string][]string{
|
||||
"Username": {"error1", "error2"},
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader(""))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
t.Equal(http.StatusBadRequest, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"errors": {
|
||||
"username": [
|
||||
"error1",
|
||||
"error2"
|
||||
]
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("receive other error", func() {
|
||||
t.ProfilesManager.On("PersistProfile", mock.Anything, mock.Anything).Once().Return(errors.New("mock error"))
|
||||
|
||||
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader(""))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
result := w.Result()
|
||||
|
||||
t.Equal(http.StatusInternalServerError, result.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *ProfilesTestSuite) TestDeleteProfileByUuid() {
|
||||
t.Run("successfully delete", func() {
|
||||
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything, "0f657aa8-bfbe-415d-b700-5750090d3af3").Once().Return(nil)
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
t.Equal(http.StatusNoContent, resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("error from manager", func() {
|
||||
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything, mock.Anything).Return(errors.New("mock error"))
|
||||
|
||||
req := httptest.NewRequest("DELETE", "http://chrly/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
t.Equal(http.StatusInternalServerError, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProfilesApi(t *testing.T) {
|
||||
suite.Run(t, new(ProfilesTestSuite))
|
||||
}
|
||||
263
internal/http/skinsystem.go
Normal file
263
internal/http/skinsystem.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
"ely.by/chrly/internal/mojang"
|
||||
"ely.by/chrly/internal/otel"
|
||||
)
|
||||
|
||||
type ProfilesProvider interface {
|
||||
FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error)
|
||||
}
|
||||
|
||||
func NewSkinsystemApi(
|
||||
profilesProvider ProfilesProvider,
|
||||
texturesExtraParamName string,
|
||||
texturesExtraParamValue string,
|
||||
) (*Skinsystem, error) {
|
||||
metrics, err := newSkinsystemMetrics(otel.GetMeter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Skinsystem{
|
||||
ProfilesProvider: profilesProvider,
|
||||
TexturesExtraParamName: texturesExtraParamName,
|
||||
TexturesExtraParamValue: texturesExtraParamValue,
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Skinsystem struct {
|
||||
ProfilesProvider
|
||||
TexturesExtraParamName string
|
||||
TexturesExtraParamValue string
|
||||
metrics *skinsystemApiMetrics
|
||||
}
|
||||
|
||||
func (s *Skinsystem) Handler() *mux.Router {
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
|
||||
router.HandleFunc("/skins/{username}", s.skinHandler).Methods(http.MethodGet)
|
||||
router.HandleFunc("/cloaks/{username}", s.capeHandler).Methods(http.MethodGet)
|
||||
// TODO: alias /capes/{username}?
|
||||
router.HandleFunc("/textures/{username}", s.texturesHandler).Methods(http.MethodGet)
|
||||
router.HandleFunc("/textures/signed/{username}", s.signedTexturesHandler).Methods(http.MethodGet)
|
||||
// Legacy
|
||||
router.HandleFunc("/skins", s.legacySkinHandler).Methods(http.MethodGet)
|
||||
router.HandleFunc("/cloaks", s.legacyCapeHandler).Methods(http.MethodGet)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func (s *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
|
||||
s.metrics.SkinRequest.Add(request.Context(), 1)
|
||||
|
||||
s.skinHandlerWithUsername(response, request, mux.Vars(request)["username"])
|
||||
}
|
||||
|
||||
func (s *Skinsystem) legacySkinHandler(response http.ResponseWriter, request *http.Request) {
|
||||
s.metrics.LegacySkinRequest.Add(request.Context(), 1)
|
||||
|
||||
username := request.URL.Query().Get("name")
|
||||
if username == "" {
|
||||
response.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.skinHandlerWithUsername(response, request, username)
|
||||
}
|
||||
|
||||
func (s *Skinsystem) skinHandlerWithUsername(resp http.ResponseWriter, req *http.Request, username string) {
|
||||
profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), parseUsername(username), true)
|
||||
if err != nil {
|
||||
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil || profile.SkinUrl == "" {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
http.Redirect(resp, req, profile.SkinUrl, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
func (s *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
|
||||
s.metrics.CapeRequest.Add(request.Context(), 1)
|
||||
|
||||
s.capeHandlerWithUsername(response, request, mux.Vars(request)["username"])
|
||||
}
|
||||
|
||||
func (s *Skinsystem) legacyCapeHandler(response http.ResponseWriter, request *http.Request) {
|
||||
s.metrics.CapeRequest.Add(request.Context(), 1)
|
||||
|
||||
username := request.URL.Query().Get("name")
|
||||
if username == "" {
|
||||
response.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.capeHandlerWithUsername(response, request, username)
|
||||
}
|
||||
|
||||
func (s *Skinsystem) capeHandlerWithUsername(resp http.ResponseWriter, req *http.Request, username string) {
|
||||
profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), parseUsername(username), true)
|
||||
if err != nil {
|
||||
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil || profile.CapeUrl == "" {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
|
||||
http.Redirect(resp, req, profile.CapeUrl, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
func (s *Skinsystem) texturesHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
s.metrics.TexturesRequest.Add(req.Context(), 1)
|
||||
|
||||
profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), mux.Vars(req)["username"], true)
|
||||
if err != nil {
|
||||
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if profile.SkinUrl == "" && profile.CapeUrl == "" {
|
||||
resp.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
textures := texturesFromProfile(profile)
|
||||
|
||||
responseData, _ := json.Marshal(textures)
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
_, _ = resp.Write(responseData)
|
||||
}
|
||||
|
||||
func (s *Skinsystem) signedTexturesHandler(resp http.ResponseWriter, req *http.Request) {
|
||||
s.metrics.SignedTexturesRequest.Add(req.Context(), 1)
|
||||
|
||||
profile, err := s.ProfilesProvider.FindProfileByUsername(
|
||||
req.Context(),
|
||||
mux.Vars(req)["username"],
|
||||
getToBool(req.URL.Query().Get("proxy")),
|
||||
)
|
||||
if err != nil {
|
||||
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
resp.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if profile.MojangTextures == "" {
|
||||
resp.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
profileResponse := &mojang.ProfileResponse{
|
||||
Id: profile.Uuid,
|
||||
Name: profile.Username,
|
||||
Props: []*mojang.Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Signature: profile.MojangSignature,
|
||||
Value: profile.MojangTextures,
|
||||
},
|
||||
{
|
||||
Name: s.TexturesExtraParamName,
|
||||
Value: s.TexturesExtraParamValue,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
responseJson, _ := json.Marshal(profileResponse)
|
||||
resp.Header().Set("Content-Type", "application/json")
|
||||
_, _ = resp.Write(responseJson)
|
||||
}
|
||||
|
||||
func parseUsername(username string) string {
|
||||
return strings.TrimSuffix(username, ".png")
|
||||
}
|
||||
|
||||
func getToBool(v string) bool {
|
||||
return v == "1" || v == "true" || v == "yes"
|
||||
}
|
||||
|
||||
func texturesFromProfile(profile *db.Profile) *mojang.TexturesResponse {
|
||||
var skin *mojang.SkinTexturesResponse
|
||||
if profile.SkinUrl != "" {
|
||||
skin = &mojang.SkinTexturesResponse{
|
||||
Url: profile.SkinUrl,
|
||||
}
|
||||
if profile.SkinModel != "" {
|
||||
skin.Metadata = &mojang.SkinTexturesMetadata{
|
||||
Model: profile.SkinModel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var cape *mojang.CapeTexturesResponse
|
||||
if profile.CapeUrl != "" {
|
||||
cape = &mojang.CapeTexturesResponse{
|
||||
Url: profile.CapeUrl,
|
||||
}
|
||||
}
|
||||
|
||||
return &mojang.TexturesResponse{
|
||||
Skin: skin,
|
||||
Cape: cape,
|
||||
}
|
||||
}
|
||||
|
||||
func newSkinsystemMetrics(meter metric.Meter) (*skinsystemApiMetrics, error) {
|
||||
m := &skinsystemApiMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.SkinRequest, err = meter.Int64Counter("chrly.app.skinsystem.skin.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.LegacySkinRequest, err = meter.Int64Counter("chrly.app.skinsystem.legacy_skin.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.CapeRequest, err = meter.Int64Counter("chrly.app.skinsystem.cape.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.LegacyCapeRequest, err = meter.Int64Counter("chrly.app.skinsystem.legacy_cape.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.TexturesRequest, err = meter.Int64Counter("chrly.app.skinsystem.textures.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.SignedTexturesRequest, err = meter.Int64Counter("chrly.app.skinsystem.signed_textures.request", metric.WithUnit("{request}"))
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type skinsystemApiMetrics struct {
|
||||
SkinRequest metric.Int64Counter
|
||||
LegacySkinRequest metric.Int64Counter
|
||||
CapeRequest metric.Int64Counter
|
||||
LegacyCapeRequest metric.Int64Counter
|
||||
TexturesRequest metric.Int64Counter
|
||||
SignedTexturesRequest metric.Int64Counter
|
||||
}
|
||||
402
internal/http/skinsystem_test.go
Normal file
402
internal/http/skinsystem_test.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
testify "github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
)
|
||||
|
||||
type ProfilesProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *ProfilesProviderMock) FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error) {
|
||||
args := m.Called(ctx, username, allowProxy)
|
||||
var result *db.Profile
|
||||
if casted, ok := args.Get(0).(*db.Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type SkinsystemTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
App *Skinsystem
|
||||
|
||||
ProfilesProvider *ProfilesProviderMock
|
||||
}
|
||||
|
||||
/********************
|
||||
* Setup test suite *
|
||||
********************/
|
||||
|
||||
func (t *SkinsystemTestSuite) SetupSubTest() {
|
||||
t.ProfilesProvider = &ProfilesProviderMock{}
|
||||
|
||||
t.App, _ = NewSkinsystemApi(
|
||||
t.ProfilesProvider,
|
||||
"texturesParamName",
|
||||
"texturesParamValue",
|
||||
)
|
||||
}
|
||||
|
||||
func (t *SkinsystemTestSuite) TearDownSubTest() {
|
||||
t.ProfilesProvider.AssertExpectations(t.T())
|
||||
}
|
||||
|
||||
func (t *SkinsystemTestSuite) TestSkinHandler() {
|
||||
for _, url := range []string{"http://chrly/skins/mock_username", "http://chrly/skins?name=mock_username"} {
|
||||
t.Run("known username with a skin", func() {
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// TODO: see the TODO about context above
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusMovedPermanently, result.StatusCode)
|
||||
t.Equal("https://example.com/skin.png", result.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("known username without a skin", func() {
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusNotFound, result.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("err from profiles provider", func() {
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error"))
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusInternalServerError, result.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("username with png extension", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusMovedPermanently, result.StatusCode)
|
||||
t.Equal("https://example.com/skin.png", result.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("no name param", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
t.Equal(http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *SkinsystemTestSuite) TestCapeHandler() {
|
||||
for _, url := range []string{"http://chrly/cloaks/mock_username", "http://chrly/cloaks?name=mock_username"} {
|
||||
t.Run("known username with a skin", func() {
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// TODO: I can't find a way to verify that it's the context from the request that was passed in,
|
||||
// as the Mux calls WithValue() on it, which creates a new Context and I haven't been able
|
||||
// to find a way to verify that the passed context matches the base
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusMovedPermanently, result.StatusCode)
|
||||
t.Equal("https://example.com/cape.png", result.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("known username without a skin", func() {
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusNotFound, result.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("err from profiles provider", func() {
|
||||
req := httptest.NewRequest("GET", url, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error"))
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusInternalServerError, result.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("username with png extension", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusMovedPermanently, result.StatusCode)
|
||||
t.Equal("https://example.com/cape.png", result.Header.Get("Location"))
|
||||
})
|
||||
|
||||
t.Run("no name param", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
t.Equal(http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *SkinsystemTestSuite) TestTexturesHandler() {
|
||||
t.Run("known username with both textures", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// TODO: see the TODO about context above
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusOK, result.StatusCode)
|
||||
t.Equal("application/json", result.Header.Get("Content-Type"))
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"SKIN": {
|
||||
"url": "https://example.com/skin.png"
|
||||
},
|
||||
"CAPE": {
|
||||
"url": "https://example.com/cape.png"
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("known username with only slim skin", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "slim",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"SKIN": {
|
||||
"url": "https://example.com/skin.png",
|
||||
"metadata": {
|
||||
"model": "slim"
|
||||
}
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("known username with only cape", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"CAPE": {
|
||||
"url": "https://example.com/cape.png"
|
||||
}
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("known username without any textures", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusNoContent, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("unknown username", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusNotFound, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("err from profiles provider", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error"))
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusInternalServerError, result.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *SkinsystemTestSuite) TestSignedTextures() {
|
||||
t.Run("exists profile with mojang textures", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// TODO: see the TODO about context above
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(&db.Profile{
|
||||
Uuid: "mock-uuid",
|
||||
Username: "mock",
|
||||
MojangTextures: "mock-mojang-textures",
|
||||
MojangSignature: "mock-mojang-signature",
|
||||
}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusOK, result.StatusCode)
|
||||
t.Equal("application/json", result.Header.Get("Content-Type"))
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.JSONEq(`{
|
||||
"id": "mock-uuid",
|
||||
"name": "mock",
|
||||
"properties": [
|
||||
{
|
||||
"name": "textures",
|
||||
"signature": "mock-mojang-signature",
|
||||
"value": "mock-mojang-textures"
|
||||
},
|
||||
{
|
||||
"name": "texturesParamName",
|
||||
"value": "texturesParamValue"
|
||||
}
|
||||
]
|
||||
}`, string(body))
|
||||
})
|
||||
|
||||
t.Run("exists profile without mojang textures", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(&db.Profile{}, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusNoContent, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("not exists profile", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(nil, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusNotFound, result.StatusCode)
|
||||
body, _ := io.ReadAll(result.Body)
|
||||
t.Empty(body)
|
||||
})
|
||||
|
||||
t.Run("err from profiles provider", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(nil, errors.New("mock error"))
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
|
||||
result := w.Result()
|
||||
t.Equal(http.StatusInternalServerError, result.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("should allow proxying when specified get param", func() {
|
||||
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username?proxy=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, nil)
|
||||
|
||||
t.App.Handler().ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkinsystem(t *testing.T) {
|
||||
suite.Run(t, new(SkinsystemTestSuite))
|
||||
}
|
||||
|
||||
func TestParseUsername(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
|
||||
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
|
||||
}
|
||||
208
internal/mojang/batch_uuids_provider.go
Normal file
208
internal/mojang/batch_uuids_provider.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"ely.by/chrly/internal/otel"
|
||||
"ely.by/chrly/internal/utils"
|
||||
)
|
||||
|
||||
type UsernamesToUuidsEndpoint func(ctx context.Context, usernames []string) ([]*ProfileInfo, error)
|
||||
|
||||
type BatchUuidsProvider struct {
|
||||
UsernamesToUuidsEndpoint
|
||||
batch int
|
||||
delay time.Duration
|
||||
fireOnFull bool
|
||||
|
||||
queue *utils.Queue[*job]
|
||||
fireChan chan any
|
||||
stopChan chan any
|
||||
onFirstCall sync.Once
|
||||
metrics *batchUuidsProviderMetrics
|
||||
}
|
||||
|
||||
func NewBatchUuidsProvider(
|
||||
endpoint UsernamesToUuidsEndpoint,
|
||||
batchSize int,
|
||||
awaitDelay time.Duration,
|
||||
fireOnFull bool,
|
||||
) (*BatchUuidsProvider, error) {
|
||||
queue := utils.NewQueue[*job]()
|
||||
|
||||
metrics, err := newBatchUuidsProviderMetrics(otel.GetMeter(), queue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &BatchUuidsProvider{
|
||||
UsernamesToUuidsEndpoint: endpoint,
|
||||
stopChan: make(chan any),
|
||||
batch: batchSize,
|
||||
delay: awaitDelay,
|
||||
fireOnFull: fireOnFull,
|
||||
queue: queue,
|
||||
fireChan: make(chan any),
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type job struct {
|
||||
Username string
|
||||
Ctx context.Context
|
||||
QueuingTime time.Time
|
||||
ResultChan chan<- *jobResult
|
||||
}
|
||||
|
||||
type jobResult struct {
|
||||
Profile *ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
func (p *BatchUuidsProvider) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
|
||||
resultChan := make(chan *jobResult)
|
||||
n := p.queue.Enqueue(&job{username, ctx, time.Now(), resultChan})
|
||||
if p.fireOnFull && n%p.batch == 0 {
|
||||
p.fireChan <- struct{}{}
|
||||
}
|
||||
|
||||
p.onFirstCall.Do(p.startQueue)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case result := <-resultChan:
|
||||
return result.Profile, result.Error
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BatchUuidsProvider) StopQueue() {
|
||||
close(p.stopChan)
|
||||
}
|
||||
|
||||
func (p *BatchUuidsProvider) startQueue() {
|
||||
go func() {
|
||||
for {
|
||||
t := time.NewTimer(p.delay)
|
||||
select {
|
||||
case <-p.stopChan:
|
||||
return
|
||||
case <-t.C:
|
||||
go p.fireRequest()
|
||||
case <-p.fireChan:
|
||||
t.Stop()
|
||||
go p.fireRequest()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *BatchUuidsProvider) fireRequest() {
|
||||
// Since this method is an aggregator, it uses its own context to manage its lifetime
|
||||
reqCtx := context.Background()
|
||||
jobs := make([]*job, 0, p.batch)
|
||||
n := p.batch
|
||||
for {
|
||||
foundJobs, left := p.queue.Dequeue(n)
|
||||
for i := range foundJobs {
|
||||
p.metrics.QueueTime.Record(reqCtx, float64(time.Since(foundJobs[i].QueuingTime).Milliseconds()))
|
||||
if foundJobs[i].Ctx.Err() != nil {
|
||||
// If the job context has already ended, its result will be returned in the GetUuid method
|
||||
close(foundJobs[i].ResultChan)
|
||||
|
||||
foundJobs[i] = foundJobs[len(foundJobs)-1]
|
||||
foundJobs = foundJobs[:len(foundJobs)-1]
|
||||
}
|
||||
}
|
||||
|
||||
jobs = append(jobs, foundJobs...)
|
||||
if len(jobs) != p.batch && left != 0 {
|
||||
n = p.batch - len(jobs)
|
||||
continue
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
usernames := make([]string, len(jobs))
|
||||
for i, job := range jobs {
|
||||
usernames[i] = job.Username
|
||||
}
|
||||
|
||||
p.metrics.Requests.Add(reqCtx, 1)
|
||||
p.metrics.BatchSize.Record(reqCtx, int64(len(usernames)))
|
||||
|
||||
profiles, err := p.UsernamesToUuidsEndpoint(reqCtx, usernames)
|
||||
for _, job := range 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.ResultChan <- response
|
||||
close(job.ResultChan)
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchUuidsProviderMetrics(meter metric.Meter, queue *utils.Queue[*job]) (*batchUuidsProviderMetrics, error) {
|
||||
m := &batchUuidsProviderMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.Requests, err = meter.Int64Counter(
|
||||
"chrly.mojang.uuids.batch.request.sent",
|
||||
metric.WithDescription("Number of UUIDs requests sent to Mojang API"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.BatchSize, err = meter.Int64Histogram(
|
||||
"chrly.mojang.uuids.batch.request.batch_size",
|
||||
metric.WithDescription("The number of usernames in the query"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.QueueLength, err = meter.Int64ObservableGauge(
|
||||
"chrly.mojang.uuids.batch.queue.length",
|
||||
metric.WithDescription("Number of tasks in the queue waiting for execution"),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
o.Observe(int64(queue.Len()))
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.QueueTime, err = meter.Float64Histogram(
|
||||
"chrly.mojang.uuids.batch.queue.lag",
|
||||
metric.WithDescription("Lag between placing a job in the queue and starting its processing"),
|
||||
metric.WithUnit("ms"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type batchUuidsProviderMetrics struct {
|
||||
Requests metric.Int64Counter
|
||||
BatchSize metric.Int64Histogram
|
||||
QueueLength metric.Int64ObservableGauge
|
||||
QueueTime metric.Float64Histogram
|
||||
}
|
||||
210
internal/mojang/batch_uuids_provider_test.go
Normal file
210
internal/mojang/batch_uuids_provider_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var awaitDelay = 20 * time.Millisecond
|
||||
|
||||
type mojangUsernamesToUuidsRequestMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(ctx context.Context, usernames []string) ([]*ProfileInfo, error) {
|
||||
args := o.Called(ctx, usernames)
|
||||
var result []*ProfileInfo
|
||||
if casted, ok := args.Get(0).([]*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type batchUuidsProviderGetUuidResult struct {
|
||||
Result *ProfileInfo
|
||||
Error error
|
||||
}
|
||||
|
||||
type batchUuidsProviderTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *BatchUuidsProvider
|
||||
|
||||
MojangApi *mojangUsernamesToUuidsRequestMock
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) SetupTest() {
|
||||
s.MojangApi = &mojangUsernamesToUuidsRequestMock{}
|
||||
s.Provider, _ = NewBatchUuidsProvider(
|
||||
s.MojangApi.UsernamesToUuids,
|
||||
3,
|
||||
awaitDelay,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TearDownTest() {
|
||||
s.MojangApi.AssertExpectations(s.T())
|
||||
s.Provider.StopQueue()
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
|
||||
return s.GetUuidAsyncWithCtx(context.Background(), username)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) GetUuidAsyncWithCtx(ctx context.Context, username string) <-chan *batchUuidsProviderGetUuidResult {
|
||||
startedChan := make(chan any)
|
||||
c := make(chan *batchUuidsProviderGetUuidResult, 1)
|
||||
go func() {
|
||||
close(startedChan)
|
||||
profile, err := s.Provider.GetUuid(ctx, username)
|
||||
c <- &batchUuidsProviderGetUuidResult{
|
||||
Result: profile,
|
||||
Error: err,
|
||||
}
|
||||
close(c)
|
||||
}()
|
||||
|
||||
<-startedChan
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesSuccessfully() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedResult1 := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
|
||||
expectedResult2 := &ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, expectedUsernames).Once().Return([]*ProfileInfo{
|
||||
expectedResult1,
|
||||
expectedResult2,
|
||||
}, nil)
|
||||
|
||||
chan1 := s.GetUuidAsync("username1")
|
||||
chan2 := s.GetUuidAsync("username2")
|
||||
|
||||
s.Require().Empty(chan1)
|
||||
s.Require().Empty(chan2)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
result1 := <-chan1
|
||||
result2 := <-chan2
|
||||
|
||||
s.Require().NoError(result1.Error)
|
||||
s.Require().Equal(expectedResult1, result1.Result)
|
||||
|
||||
s.Require().NoError(result2.Error)
|
||||
s.Require().Equal(expectedResult2, result2.Result)
|
||||
|
||||
// Await a few more iterations to ensure, that no requests will be performed when there are no additional tasks
|
||||
time.Sleep(awaitDelay * 3)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesSplitByMultipleIterations() {
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsync("username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan3)
|
||||
s.Require().Empty(resultChan4)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesWhenOneOfContextIsDeadlined() {
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username1", "username2", "username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
ctx, cancelCtx := context.WithCancel(context.Background())
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsyncWithCtx(ctx, "username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
cancelCtx()
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 0.5))
|
||||
|
||||
s.Empty(resultChan1)
|
||||
s.Empty(resultChan2)
|
||||
s.NotEmpty(resultChan3, "canceled context must immediately release the job")
|
||||
s.Empty(resultChan4)
|
||||
|
||||
result3 := <-resultChan3
|
||||
s.Nil(result3.Result)
|
||||
s.ErrorIs(result3.Error, context.Canceled)
|
||||
|
||||
time.Sleep(awaitDelay)
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesFireOnFull() {
|
||||
s.Provider.fireOnFull = true
|
||||
|
||||
var emptyResponse []string
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username4"}).Once().Return(emptyResponse, nil)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
resultChan3 := s.GetUuidAsync("username3")
|
||||
resultChan4 := s.GetUuidAsync("username4")
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 0.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan1)
|
||||
s.Require().NotEmpty(resultChan2)
|
||||
s.Require().NotEmpty(resultChan3)
|
||||
s.Require().Empty(resultChan4)
|
||||
|
||||
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
|
||||
|
||||
s.Require().NotEmpty(resultChan4)
|
||||
}
|
||||
|
||||
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
|
||||
expectedUsernames := []string{"username1", "username2"}
|
||||
expectedError := errors.New("mock error")
|
||||
|
||||
s.MojangApi.On("UsernamesToUuids", mock.Anything, expectedUsernames).Once().Return(nil, expectedError)
|
||||
|
||||
resultChan1 := s.GetUuidAsync("username1")
|
||||
resultChan2 := s.GetUuidAsync("username2")
|
||||
|
||||
result1 := <-resultChan1
|
||||
s.Assert().Nil(result1.Result)
|
||||
s.Assert().Equal(expectedError, result1.Error)
|
||||
|
||||
result2 := <-resultChan2
|
||||
s.Assert().Nil(result2.Result)
|
||||
s.Assert().Equal(expectedError, result2.Error)
|
||||
}
|
||||
|
||||
func TestBatchUuidsProvider(t *testing.T) {
|
||||
suite.Run(t, new(batchUuidsProviderTestSuite))
|
||||
}
|
||||
266
internal/mojang/client.go
Normal file
266
internal/mojang/client.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type MojangApi struct {
|
||||
http *http.Client
|
||||
batchUuidsUrl string
|
||||
profileUrl string
|
||||
}
|
||||
|
||||
func NewMojangApi(
|
||||
http *http.Client,
|
||||
batchUuidsUrl string,
|
||||
profileUrl string,
|
||||
) *MojangApi {
|
||||
if batchUuidsUrl == "" {
|
||||
batchUuidsUrl = "https://api.mojang.com/profiles/minecraft"
|
||||
}
|
||||
|
||||
if profileUrl == "" {
|
||||
profileUrl = "https://sessionserver.mojang.com/session/minecraft/profile/"
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(profileUrl, "/") {
|
||||
profileUrl += "/"
|
||||
}
|
||||
|
||||
return &MojangApi{
|
||||
http,
|
||||
batchUuidsUrl,
|
||||
profileUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Exchanges usernames array to array of uuids
|
||||
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
|
||||
func (c *MojangApi) UsernamesToUuids(ctx context.Context, usernames []string) ([]*ProfileInfo, error) {
|
||||
requestBody, _ := json.Marshal(usernames)
|
||||
request, err := http.NewRequestWithContext(ctx, "POST", c.batchUuidsUrl, bytes.NewBuffer(requestBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errorFromResponse(response)
|
||||
}
|
||||
|
||||
var result []*ProfileInfo
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Obtains textures information for provided uuid
|
||||
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
|
||||
func (c *MojangApi) UuidToTextures(ctx context.Context, uuid string, signed bool) (*ProfileResponse, error) {
|
||||
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
|
||||
url := c.profileUrl + normalizedUuid
|
||||
if signed {
|
||||
url += "?unsigned=false"
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response, err := c.http.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode == 204 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if response.StatusCode != 200 {
|
||||
return nil, errorFromResponse(response)
|
||||
}
|
||||
|
||||
var result *ProfileResponse
|
||||
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
err = json.Unmarshal(body, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Props []*Property `json:"properties"`
|
||||
|
||||
once sync.Once
|
||||
decodedTextures *TexturesProp
|
||||
decodedErr error
|
||||
}
|
||||
|
||||
type TexturesProp struct {
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Textures *TexturesResponse `json:"textures"`
|
||||
}
|
||||
|
||||
type TexturesResponse struct {
|
||||
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
|
||||
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SkinTexturesMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type CapeTexturesResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func (t *ProfileResponse) DecodeTextures() (*TexturesProp, error) {
|
||||
t.once.Do(func() {
|
||||
var texturesProp string
|
||||
for _, prop := range t.Props {
|
||||
if prop.Name == "textures" {
|
||||
texturesProp = prop.Value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if texturesProp == "" {
|
||||
return
|
||||
}
|
||||
|
||||
decodedTextures, err := DecodeTextures(texturesProp)
|
||||
if err != nil {
|
||||
t.decodedErr = err
|
||||
} else {
|
||||
t.decodedTextures = decodedTextures
|
||||
}
|
||||
})
|
||||
|
||||
return t.decodedTextures, t.decodedErr
|
||||
}
|
||||
|
||||
type Property struct {
|
||||
Name string `json:"name"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ProfileInfo struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsLegacy bool `json:"legacy,omitempty"`
|
||||
IsDemo bool `json:"demo,omitempty"`
|
||||
}
|
||||
|
||||
func errorFromResponse(response *http.Response) error {
|
||||
switch {
|
||||
case response.StatusCode == 400:
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"errorMessage"`
|
||||
}
|
||||
|
||||
var decodedError *errorResponse
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
_ = 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:
|
||||
return &ServerError{Status: response.StatusCode}
|
||||
}
|
||||
|
||||
return fmt.Errorf("unexpected response status code: %d", response.StatusCode)
|
||||
}
|
||||
|
||||
// When passed request params are invalid, Mojang returns 400 Bad Request error
|
||||
type BadRequestError struct {
|
||||
ErrorType string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *BadRequestError) Error() string {
|
||||
return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
|
||||
}
|
||||
|
||||
// When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization)
|
||||
type ForbiddenError struct {
|
||||
}
|
||||
|
||||
func (*ForbiddenError) Error() string {
|
||||
return "403: Forbidden"
|
||||
}
|
||||
|
||||
// When you exceed the set limit of requests, this error will be returned
|
||||
type TooManyRequestsError struct {
|
||||
}
|
||||
|
||||
func (*TooManyRequestsError) Error() string {
|
||||
return "429: Too Many Requests"
|
||||
}
|
||||
|
||||
// ServerError happens when Mojang's API returns any response with 50* status
|
||||
type ServerError struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *ServerError) Error() string {
|
||||
return fmt.Sprintf("%d: %s", e.Status, "Server error")
|
||||
}
|
||||
|
||||
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
|
||||
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *TexturesProp
|
||||
err = json.Unmarshal(jsonStr, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EncodeTextures(textures *TexturesProp) string {
|
||||
jsonSerialized, _ := json.Marshal(textures)
|
||||
return base64.URLEncoding.EncodeToString(jsonSerialized)
|
||||
}
|
||||
318
internal/mojang/client_test.go
Normal file
318
internal/mojang/client_test.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/h2non/gock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type MojangApiSuite struct {
|
||||
suite.Suite
|
||||
api *MojangApi
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) SetupTest() {
|
||||
httpClient := &http.Client{}
|
||||
gock.InterceptClient(httpClient)
|
||||
s.api = NewMojangApi(httpClient, "", "")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TearDownTest() {
|
||||
gock.Off()
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsSuccessfully() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
JSON([]string{"Thinkofdeath", "maksimkurb"}).
|
||||
Reply(200).
|
||||
JSON([]map[string]any{
|
||||
{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"legacy": false,
|
||||
"demo": true,
|
||||
},
|
||||
{
|
||||
"id": "0d252b7218b648bfb86c2ae476954d32",
|
||||
"name": "maksimkurb",
|
||||
// There are no legacy or demo fields
|
||||
},
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
|
||||
s.NoError(err)
|
||||
s.Len(result, 2)
|
||||
s.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
|
||||
s.Equal("Thinkofdeath", result[0].Name)
|
||||
s.False(result[0].IsLegacy)
|
||||
s.True(result[0].IsDemo)
|
||||
|
||||
s.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
|
||||
s.Equal("maksimkurb", result[1].Name)
|
||||
s.False(result[1].IsLegacy)
|
||||
s.False(result[1].IsDemo)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsBadRequest() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(400).
|
||||
JSON(map[string]any{
|
||||
"error": "IllegalArgumentException",
|
||||
"errorMessage": "profileName can not be null or empty.",
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids(context.Background(), []string{""})
|
||||
s.Nil(result)
|
||||
s.IsType(&BadRequestError{}, err)
|
||||
s.EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsForbidden() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(403).
|
||||
BodyString("just because")
|
||||
|
||||
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Nil(result)
|
||||
s.IsType(&ForbiddenError{}, err)
|
||||
s.EqualError(err, "403: Forbidden")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsTooManyRequests() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(429).
|
||||
JSON(map[string]any{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Nil(result)
|
||||
s.IsType(&TooManyRequestsError{}, err)
|
||||
s.EqualError(err, "429: Too Many Requests")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUsernamesToUuidsServerError() {
|
||||
gock.New("https://api.mojang.com").
|
||||
Post("/profiles/minecraft").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
|
||||
s.Nil(result)
|
||||
s.IsType(&ServerError{}, err)
|
||||
s.EqualError(err, "500: Server error")
|
||||
s.Equal(500, err.(*ServerError).Status)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesSuccessfulResponse() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(200).
|
||||
JSON(map[string]any{
|
||||
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
|
||||
"name": "Thinkofdeath",
|
||||
"properties": []any{
|
||||
map[string]any{
|
||||
"name": "textures",
|
||||
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.NoError(err)
|
||||
s.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
|
||||
s.Equal("Thinkofdeath", result.Name)
|
||||
s.Equal(1, len(result.Props))
|
||||
s.Equal("textures", result.Props[0].Name)
|
||||
s.Equal(476, len(result.Props[0].Value))
|
||||
s.Equal("", result.Props[0].Signature)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesEmptyResponse() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(204).
|
||||
BodyString("")
|
||||
|
||||
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.NoError(err)
|
||||
s.Nil(result)
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesTooManyRequests() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(429).
|
||||
JSON(map[string]any{
|
||||
"error": "TooManyRequestsException",
|
||||
"errorMessage": "The client has sent too many requests within a certain amount of time",
|
||||
})
|
||||
|
||||
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Nil(result)
|
||||
s.IsType(&TooManyRequestsError{}, err)
|
||||
s.EqualError(err, "429: Too Many Requests")
|
||||
}
|
||||
|
||||
func (s *MojangApiSuite) TestUuidToTexturesServerError() {
|
||||
gock.New("https://sessionserver.mojang.com").
|
||||
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
|
||||
Reply(500).
|
||||
BodyString("500 Internal Server Error")
|
||||
|
||||
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
|
||||
s.Nil(result)
|
||||
s.IsType(&ServerError{}, err)
|
||||
s.EqualError(err, "500: Server error")
|
||||
s.Equal(500, err.(*ServerError).Status)
|
||||
}
|
||||
|
||||
func TestMojangApi(t *testing.T) {
|
||||
suite.Run(t, new(MojangApiSuite))
|
||||
}
|
||||
|
||||
func TestProfileResponse(t *testing.T) {
|
||||
t.Run("DecodeTextures", func(t *testing.T) {
|
||||
obj := &ProfileResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
},
|
||||
},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
|
||||
})
|
||||
|
||||
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
|
||||
obj := &ProfileResponse{
|
||||
Id: "00000000000000000000000000000000",
|
||||
Name: "mock",
|
||||
Props: []*Property{},
|
||||
}
|
||||
textures, err := obj.DecodeTextures()
|
||||
testify.Nil(t, err)
|
||||
testify.Nil(t, textures)
|
||||
})
|
||||
}
|
||||
|
||||
type texturesTestCase struct {
|
||||
Name string
|
||||
Encoded string
|
||||
Decoded *TexturesProp
|
||||
}
|
||||
|
||||
var texturesTestCases = []*texturesTestCase{
|
||||
{
|
||||
Name: "property without textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856010494),
|
||||
Textures: &TexturesResponse{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with classic skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856307412),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with alex skin textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
|
||||
ProfileName: "ErickSkrauch",
|
||||
Timestamp: int64(1555856494791),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
|
||||
Metadata: &SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "property with skin and cape textures",
|
||||
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
|
||||
Decoded: &TexturesProp{
|
||||
ProfileID: "d90b68bc81724329a047f1186dcd4336",
|
||||
ProfileName: "akronman1",
|
||||
Timestamp: int64(1555857675335),
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
|
||||
},
|
||||
Cape: &CapeTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestDecodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("decode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures(testCase.Encoded)
|
||||
assert.Nil(err)
|
||||
assert.Equal(testCase.Decoded, result)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("invalid base64")
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
|
||||
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
|
||||
assert.Error(err)
|
||||
assert.Nil(result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncodeTextures(t *testing.T) {
|
||||
for _, testCase := range texturesTestCases {
|
||||
t.Run("encode "+testCase.Name, func(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
result := EncodeTextures(testCase.Decoded)
|
||||
assert.Equal(testCase.Encoded, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
174
internal/mojang/provider.go
Normal file
174
internal/mojang/provider.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/brunomvsouza/singleflight"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"ely.by/chrly/internal/otel"
|
||||
)
|
||||
|
||||
const ScopeName = "ely.by/chrly/internal/mojang"
|
||||
|
||||
var InvalidUsername = errors.New("the username passed doesn't meet Mojang's requirements")
|
||||
|
||||
// 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(ctx context.Context, username string) (*ProfileInfo, error)
|
||||
}
|
||||
|
||||
type TexturesProvider interface {
|
||||
GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error)
|
||||
}
|
||||
|
||||
func NewMojangTexturesProvider(
|
||||
uuidsProvider UuidsProvider,
|
||||
texturesProvider TexturesProvider,
|
||||
) (*MojangTexturesProvider, error) {
|
||||
meter, err := newProviderMetrics(otel.GetMeter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MojangTexturesProvider{
|
||||
UuidsProvider: uuidsProvider,
|
||||
TexturesProvider: texturesProvider,
|
||||
metrics: meter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type MojangTexturesProvider struct {
|
||||
UuidsProvider
|
||||
TexturesProvider
|
||||
|
||||
metrics *providerMetrics
|
||||
group singleflight.Group[string, *ProfileResponse]
|
||||
}
|
||||
|
||||
func (p *MojangTexturesProvider) GetForUsername(ctx context.Context, username string) (*ProfileResponse, error) {
|
||||
if !allowedUsernamesRegex.MatchString(username) {
|
||||
return nil, InvalidUsername
|
||||
}
|
||||
|
||||
username = strings.ToLower(username)
|
||||
|
||||
result, err, shared := p.group.Do(username, func() (*ProfileResponse, error) {
|
||||
var profile *ProfileInfo
|
||||
var textures *ProfileResponse
|
||||
var err error
|
||||
|
||||
defer p.recordMetrics(ctx, profile, textures, err)
|
||||
|
||||
profile, err = p.UuidsProvider.GetUuid(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
textures, err = p.TexturesProvider.GetTextures(ctx, profile.Id)
|
||||
|
||||
return textures, err
|
||||
})
|
||||
|
||||
if shared {
|
||||
p.metrics.Shared.Add(ctx, 1)
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *MojangTexturesProvider) recordMetrics(ctx context.Context, profile *ProfileInfo, textures *ProfileResponse, err error) {
|
||||
if err != nil {
|
||||
p.metrics.Failed.Add(ctx, 1)
|
||||
return
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
p.metrics.UsernameMissed.Add(ctx, 1)
|
||||
p.metrics.TextureMissed.Add(ctx, 1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.metrics.UsernameFound.Add(ctx, 1)
|
||||
if textures != nil {
|
||||
p.metrics.TextureFound.Add(ctx, 1)
|
||||
} else {
|
||||
p.metrics.TextureMissed.Add(ctx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
type NilProvider struct {
|
||||
}
|
||||
|
||||
func (*NilProvider) GetForUsername(ctx context.Context, username string) (*ProfileResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newProviderMetrics(meter metric.Meter) (*providerMetrics, error) {
|
||||
m := &providerMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.UsernameFound, err = meter.Int64Counter(
|
||||
"mojang.provider.username_found",
|
||||
metric.WithDescription("Number of queries for which username was found"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.UsernameMissed, err = meter.Int64Counter(
|
||||
"chrly.mojang.provider.username_missed",
|
||||
metric.WithDescription("Number of queries for which username was not found"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.TextureFound, err = meter.Int64Counter(
|
||||
"chrly.mojang.provider.textures_found",
|
||||
metric.WithDescription("Number of queries for which textures were successfully found"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.TextureMissed, err = meter.Int64Counter(
|
||||
"chrly.mojang.provider.textures_missed",
|
||||
metric.WithDescription("Number of queries for which no textures were found"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.Failed, err = meter.Int64Counter(
|
||||
"chrly.mojang.provider.failed",
|
||||
metric.WithDescription("Number of requests that ended in an error"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.Shared, err = meter.Int64Counter(
|
||||
"chrly.mojang.provider.singleflight.shared",
|
||||
metric.WithDescription("Number of requests that are already being processed in another thread"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type providerMetrics struct {
|
||||
UsernameFound metric.Int64Counter
|
||||
UsernameMissed metric.Int64Counter
|
||||
TextureFound metric.Int64Counter
|
||||
TextureMissed metric.Int64Counter
|
||||
Failed metric.Int64Counter
|
||||
Shared metric.Int64Counter
|
||||
}
|
||||
180
internal/mojang/provider_test.go
Normal file
180
internal/mojang/provider_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type mockUuidsProvider struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockUuidsProvider) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
|
||||
args := m.Called(ctx, username)
|
||||
var result *ProfileInfo
|
||||
if casted, ok := args.Get(0).(*ProfileInfo); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type TexturesProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *TexturesProviderMock) GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error) {
|
||||
args := m.Called(ctx, uuid)
|
||||
var result *ProfileResponse
|
||||
if casted, ok := args.Get(0).(*ProfileResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type providerTestSuite struct {
|
||||
suite.Suite
|
||||
Provider *MojangTexturesProvider
|
||||
UuidsProvider *mockUuidsProvider
|
||||
TexturesProvider *TexturesProviderMock
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) SetupTest() {
|
||||
s.UuidsProvider = &mockUuidsProvider{}
|
||||
s.TexturesProvider = &TexturesProviderMock{}
|
||||
|
||||
s.Provider, _ = NewMojangTexturesProvider(
|
||||
s.UuidsProvider,
|
||||
s.TexturesProvider,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TearDownTest() {
|
||||
s.UuidsProvider.AssertExpectations(s.T())
|
||||
s.TexturesProvider.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetForValidUsernameSuccessfully() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
ctx := context.Background()
|
||||
|
||||
s.UuidsProvider.On("GetUuid", ctx, "username").Once().Return(expectedProfile, nil)
|
||||
s.TexturesProvider.On("GetTextures", ctx, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
|
||||
|
||||
result, err := s.Provider.GetForUsername(ctx, "username")
|
||||
|
||||
s.NoError(err)
|
||||
s.Same(expectedResult, result)
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
|
||||
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(nil, nil)
|
||||
|
||||
result, err := s.Provider.GetForUsername(context.Background(), "username")
|
||||
|
||||
s.NoError(err)
|
||||
s.Nil(result)
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(expectedProfile, nil)
|
||||
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
|
||||
|
||||
result, err := s.Provider.GetForUsername(context.Background(), "username")
|
||||
|
||||
s.NoError(err)
|
||||
s.Nil(result)
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetForTheSameUsernameInRow() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
awaitChan := make(chan time.Time)
|
||||
|
||||
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().WaitUntil(awaitChan).Return(expectedProfile, nil)
|
||||
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
|
||||
|
||||
results := make([]*ProfileResponse, 2)
|
||||
var wgStarted sync.WaitGroup
|
||||
var wgDone sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wgStarted.Add(1)
|
||||
wgDone.Add(1)
|
||||
go func(i int) {
|
||||
wgStarted.Done()
|
||||
textures, _ := s.Provider.GetForUsername(context.Background(), "username")
|
||||
results[i] = textures
|
||||
wgDone.Done()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wgStarted.Wait()
|
||||
close(awaitChan)
|
||||
wgDone.Wait()
|
||||
|
||||
s.Same(expectedResult, results[0])
|
||||
s.Same(expectedResult, results[1])
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetForTheSameUsernameOneAfterAnother() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
|
||||
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Times(2).Return(expectedProfile, nil)
|
||||
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Times(2).Return(expectedResult, nil)
|
||||
|
||||
// Just ensure that providers will be called twice
|
||||
_, _ = s.Provider.GetForUsername(context.Background(), "username")
|
||||
time.Sleep(time.Millisecond * 20)
|
||||
_, _ = s.Provider.GetForUsername(context.Background(), "username")
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetForNotAllowedMojangUsername() {
|
||||
result, err := s.Provider.GetForUsername(context.Background(), "Not allowed")
|
||||
s.ErrorIs(err, InvalidUsername)
|
||||
s.Nil(result)
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetErrorFromUuidsProvider() {
|
||||
err := errors.New("mock error")
|
||||
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(nil, err)
|
||||
|
||||
result, resErr := s.Provider.GetForUsername(context.Background(), "username")
|
||||
s.Nil(result)
|
||||
s.Equal(err, resErr)
|
||||
}
|
||||
|
||||
func (s *providerTestSuite) TestGetErrorFromTexturesProvider() {
|
||||
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
|
||||
err := errors.New("mock error")
|
||||
|
||||
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(expectedProfile, nil)
|
||||
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
|
||||
|
||||
result, resErr := s.Provider.GetForUsername(context.Background(), "username")
|
||||
s.Nil(result)
|
||||
s.Same(err, resErr)
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
suite.Run(t, new(providerTestSuite))
|
||||
}
|
||||
|
||||
func TestNilProvider_GetForUsername(t *testing.T) {
|
||||
provider := &NilProvider{}
|
||||
result, err := provider.GetForUsername(context.Background(), "username")
|
||||
require.Nil(t, result)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
141
internal/mojang/textures_provider.go
Normal file
141
internal/mojang/textures_provider.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jellydator/ttlcache/v3"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"ely.by/chrly/internal/otel"
|
||||
)
|
||||
|
||||
type MojangApiTexturesProviderFunc func(ctx context.Context, uuid string, signed bool) (*ProfileResponse, error)
|
||||
|
||||
func NewMojangApiTexturesProvider(endpoint MojangApiTexturesProviderFunc) (*MojangApiTexturesProvider, error) {
|
||||
metrics, err := newMojangApiTexturesProviderMetrics(otel.GetMeter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MojangApiTexturesProvider{
|
||||
MojangApiTexturesEndpoint: endpoint,
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type MojangApiTexturesProvider struct {
|
||||
MojangApiTexturesEndpoint MojangApiTexturesProviderFunc
|
||||
metrics *mojangApiTexturesProviderMetrics
|
||||
}
|
||||
|
||||
func (p *MojangApiTexturesProvider) GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error) {
|
||||
p.metrics.Requests.Add(ctx, 1)
|
||||
|
||||
return p.MojangApiTexturesEndpoint(ctx, uuid, true)
|
||||
}
|
||||
|
||||
// Perfectly there should be an object with provider and cache implementation,
|
||||
// but I decided not to introduce a layer and just implement cache in place.
|
||||
type TexturesProviderWithInMemoryCache struct {
|
||||
provider TexturesProvider
|
||||
once sync.Once
|
||||
cache *ttlcache.Cache[string, *ProfileResponse]
|
||||
metrics *texturesProviderWithInMemoryCacheMetrics
|
||||
}
|
||||
|
||||
func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) (*TexturesProviderWithInMemoryCache, error) {
|
||||
metrics, err := newTexturesProviderWithInMemoryCacheMetrics(otel.GetMeter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TexturesProviderWithInMemoryCache{
|
||||
provider: provider,
|
||||
cache: ttlcache.New[string, *ProfileResponse](
|
||||
ttlcache.WithDisableTouchOnHit[string, *ProfileResponse](),
|
||||
// I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error
|
||||
),
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error) {
|
||||
item := s.cache.Get(uuid)
|
||||
// Don't check item.IsExpired() since Get function is already did this check
|
||||
if item != nil {
|
||||
s.metrics.Hits.Add(ctx, 1)
|
||||
return item.Value(), nil
|
||||
}
|
||||
|
||||
s.metrics.Misses.Add(ctx, 1)
|
||||
|
||||
result, err := s.provider.GetTextures(ctx, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.cache.Set(uuid, result, time.Minute)
|
||||
// Call it only after first set so GC will work more often
|
||||
s.startGcOnce()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) StopGC() {
|
||||
// If you call the Stop() on a non-started GC, the process will hang trying to close the uninitialized channel
|
||||
s.startGcOnce()
|
||||
s.cache.Stop()
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCache) startGcOnce() {
|
||||
s.once.Do(func() {
|
||||
go s.cache.Start()
|
||||
})
|
||||
}
|
||||
|
||||
func newMojangApiTexturesProviderMetrics(meter metric.Meter) (*mojangApiTexturesProviderMetrics, error) {
|
||||
m := &mojangApiTexturesProviderMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.Requests, err = meter.Int64Counter(
|
||||
"chrly.mojang.textures.request.sent",
|
||||
metric.WithDescription("Number of textures requests sent to Mojang API"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type mojangApiTexturesProviderMetrics struct {
|
||||
Requests metric.Int64Counter
|
||||
}
|
||||
|
||||
func newTexturesProviderWithInMemoryCacheMetrics(meter metric.Meter) (*texturesProviderWithInMemoryCacheMetrics, error) {
|
||||
m := &texturesProviderWithInMemoryCacheMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.Hits, err = meter.Int64Counter(
|
||||
"chrly.mojang.textures.cache.hit",
|
||||
metric.WithDescription("Number of Mojang textures found in the local cache"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.Misses, err = meter.Int64Counter(
|
||||
"chrly.mojang.textures.cache.miss",
|
||||
metric.WithDescription("Number of Mojang textures missing from local cache"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type texturesProviderWithInMemoryCacheMetrics struct {
|
||||
Hits metric.Int64Counter
|
||||
Misses metric.Int64Counter
|
||||
}
|
||||
141
internal/mojang/textures_provider_test.go
Normal file
141
internal/mojang/textures_provider_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var signedTexturesResponse = &ProfileResponse{
|
||||
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
Name: "mock",
|
||||
Props: []*Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: EncodeTextures(&TexturesProp{
|
||||
Timestamp: time.Now().UnixNano() / 10e5,
|
||||
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
|
||||
ProfileName: "mock",
|
||||
Textures: &TexturesResponse{
|
||||
Skin: &SkinTexturesResponse{
|
||||
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type MojangUuidToTexturesRequestMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangUuidToTexturesRequestMock) UuidToTextures(ctx context.Context, uuid string, signed bool) (*ProfileResponse, error) {
|
||||
args := m.Called(ctx, uuid, signed)
|
||||
var result *ProfileResponse
|
||||
if casted, ok := args.Get(0).(*ProfileResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type MojangApiTexturesProviderSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *MojangApiTexturesProvider
|
||||
MojangApi *MojangUuidToTexturesRequestMock
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) SetupTest() {
|
||||
s.MojangApi = &MojangUuidToTexturesRequestMock{}
|
||||
s.Provider, _ = NewMojangApiTexturesProvider(s.MojangApi.UuidToTextures)
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TearDownTest() {
|
||||
s.MojangApi.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TestGetTextures() {
|
||||
ctx := context.Background()
|
||||
|
||||
s.MojangApi.On("UuidToTextures", ctx, "dead24f9a4fa4877b7b04c8c6c72bb46", true).Once().Return(signedTexturesResponse, nil)
|
||||
|
||||
result, err := s.Provider.GetTextures(ctx, "dead24f9a4fa4877b7b04c8c6c72bb46")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(signedTexturesResponse, result)
|
||||
}
|
||||
|
||||
func (s *MojangApiTexturesProviderSuite) TestGetTexturesWithError() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.MojangApi.On("UuidToTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
|
||||
|
||||
result, err := s.Provider.GetTextures(context.Background(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||
|
||||
s.Require().Nil(result)
|
||||
s.Require().Equal(expectedError, err)
|
||||
}
|
||||
|
||||
func TestMojangApiTexturesProvider(t *testing.T) {
|
||||
suite.Run(t, new(MojangApiTexturesProviderSuite))
|
||||
}
|
||||
|
||||
type TexturesProviderWithInMemoryCacheSuite struct {
|
||||
suite.Suite
|
||||
Original *TexturesProviderMock
|
||||
Provider *TexturesProviderWithInMemoryCache
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) SetupTest() {
|
||||
s.Original = &TexturesProviderMock{}
|
||||
s.Provider, _ = NewTexturesProviderWithInMemoryCache(s.Original)
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TearDownTest() {
|
||||
s.Original.AssertExpectations(s.T())
|
||||
s.Provider.StopGC()
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithSuccessfulOriginalProviderResponse() {
|
||||
ctx := context.Background()
|
||||
s.Original.On("GetTextures", ctx, "uuid").Once().Return(signedTexturesResponse, nil)
|
||||
// Do the call multiple times to ensure, that there will be only one call to the Original provider
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures(ctx, "uuid")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Same(signedTexturesResponse, result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithEmptyOriginalProviderResponse() {
|
||||
s.Original.On("GetTextures", mock.Anything, "uuid").Once().Return(nil, nil)
|
||||
// Do the call multiple times to ensure, that there will be only one call to the original provider
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures(context.Background(), "uuid")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithErrorFromOriginalProvider() {
|
||||
expectedErr := errors.New("mock error")
|
||||
s.Original.On("GetTextures", mock.Anything, "uuid").Times(5).Return(nil, expectedErr)
|
||||
// Do the call multiple times to ensure, that the error will not be cached and there will be a request on each call
|
||||
for i := 0; i < 5; i++ {
|
||||
result, err := s.Provider.GetTextures(context.Background(), "uuid")
|
||||
|
||||
s.Require().Same(expectedErr, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTexturesProviderWithInMemoryCache(t *testing.T) {
|
||||
suite.Run(t, new(TexturesProviderWithInMemoryCacheSuite))
|
||||
}
|
||||
111
internal/mojang/uuids_provider.go
Normal file
111
internal/mojang/uuids_provider.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.uber.org/multierr"
|
||||
|
||||
"ely.by/chrly/internal/otel"
|
||||
)
|
||||
|
||||
type MojangUuidsStorage interface {
|
||||
// The second argument must be returned as a incoming username in case,
|
||||
// when cached result indicates that there is no Mojang user with provided username
|
||||
GetUuidForMojangUsername(ctx context.Context, username string) (foundUuid string, foundUsername string, err error)
|
||||
// An empty uuid value can be passed if the corresponding account has not been found
|
||||
StoreMojangUuid(ctx context.Context, username string, uuid string) error
|
||||
}
|
||||
|
||||
func NewUuidsProviderWithCache(o UuidsProvider, s MojangUuidsStorage) (*UuidsProviderWithCache, error) {
|
||||
metrics, err := newUuidsProviderWithCacheMetrics(otel.GetMeter())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UuidsProviderWithCache{
|
||||
Provider: o,
|
||||
Storage: s,
|
||||
metrics: metrics,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type UuidsProviderWithCache struct {
|
||||
Provider UuidsProvider
|
||||
Storage MojangUuidsStorage
|
||||
|
||||
metrics *uuidsProviderWithCacheMetrics
|
||||
}
|
||||
|
||||
func (p *UuidsProviderWithCache) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
|
||||
var uuid, foundUsername string
|
||||
var err error
|
||||
defer p.recordMetrics(ctx, uuid, foundUsername, err)
|
||||
|
||||
uuid, foundUsername, err = p.Storage.GetUuidForMojangUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if foundUsername != "" {
|
||||
if uuid != "" {
|
||||
return &ProfileInfo{Id: uuid, Name: foundUsername}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
profile, err := p.Provider.GetUuid(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
freshUuid := ""
|
||||
wellCasedUsername := username
|
||||
if profile != nil {
|
||||
freshUuid = profile.Id
|
||||
wellCasedUsername = profile.Name
|
||||
}
|
||||
|
||||
_ = p.Storage.StoreMojangUuid(ctx, wellCasedUsername, freshUuid)
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (p *UuidsProviderWithCache) recordMetrics(ctx context.Context, uuid string, username string, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
p.metrics.Hits.Add(ctx, 1)
|
||||
} else {
|
||||
p.metrics.Misses.Add(ctx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func newUuidsProviderWithCacheMetrics(meter metric.Meter) (*uuidsProviderWithCacheMetrics, error) {
|
||||
m := &uuidsProviderWithCacheMetrics{}
|
||||
var errors, err error
|
||||
|
||||
m.Hits, err = meter.Int64Counter(
|
||||
"chrly.mojang.uuids.cache.hit",
|
||||
metric.WithDescription("Number of Mojang UUIDs found in the local cache"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
m.Misses, err = meter.Int64Counter(
|
||||
"chrly.mojang.uuids.cache.miss",
|
||||
metric.WithDescription("Number of Mojang UUIDs missing from local cache"),
|
||||
metric.WithUnit("1"),
|
||||
)
|
||||
errors = multierr.Append(errors, err)
|
||||
|
||||
return m, errors
|
||||
}
|
||||
|
||||
type uuidsProviderWithCacheMetrics struct {
|
||||
Hits metric.Int64Counter
|
||||
Misses metric.Int64Counter
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user