mirror of
https://github.com/elyby/chrly.git
synced 2025-05-31 14:11:51 +05:30
Compare commits
251 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 | ||
|
|
0cfed45b64 | ||
|
|
f872fe4698 | ||
|
|
5b4761e4e5 | ||
|
|
e81ca1520d | ||
|
|
d36fc77df0 | ||
|
|
ab78af33a5 | ||
|
|
1f057a27aa | ||
|
|
9dde5715f5 | ||
|
|
f3a8af6866 | ||
|
|
e6bac323c5 | ||
|
|
6515e3e5bd | ||
|
|
ed0b9bb040 | ||
|
|
a81c6fc9f8 | ||
|
|
8aeb1929b5 | ||
|
|
b97647318f | ||
|
|
8d619d52cd | ||
|
|
a5daae3cb8 | ||
|
|
94b930f388 | ||
|
|
f213ed45c7 | ||
|
|
6daec4dc4b | ||
|
|
90ce22f687 | ||
|
|
9250d53fb3 | ||
|
|
2c7a1625f3 | ||
|
|
f7cdab243f | ||
|
|
f3690686ec | ||
|
|
533afcc689 | ||
|
|
50a19202a5 | ||
|
|
d7f03ce182 | ||
|
|
ad300e8c1c | ||
|
|
7d1506d0d9 | ||
|
|
a8bbacf8b1 | ||
|
|
c2921400b0 | ||
|
|
e7c0fac346 | ||
|
|
bd099cfb2a | ||
|
|
96af45b2a1 | ||
|
|
b1e18d0d01 | ||
|
|
abea94a41f | ||
|
|
8244351bb5 | ||
|
|
e14619e079 | ||
|
|
fd4e5eb9ca | ||
|
|
879a33344b | ||
|
|
d2d6d07fa6 | ||
|
|
44f3ee7413 | ||
|
|
7db4d27fba | ||
|
|
4386054ca1 | ||
|
|
b73582bbf4 | ||
|
|
34598e39bc | ||
|
|
9fc6ca54d9 | ||
|
|
aed957a896 | ||
|
|
2cd97dda8b | ||
|
|
ded50df8b8 | ||
|
|
d7bc77e5a7 | ||
|
|
befa163f0e | ||
|
|
cb7adab3df | ||
|
|
87a302c7da | ||
|
|
ce4dce49a2 | ||
|
|
11647f2eae | ||
|
|
acd0237fac | ||
|
|
55f52d0ad4 | ||
|
|
778bc615aa | ||
|
|
235f65f11c | ||
|
|
8dd6a581a9 | ||
|
|
055f3ce6c0 | ||
|
|
a9f5632743 | ||
|
|
ce99ac8cf8 | ||
|
|
6192a58f63 | ||
|
|
caebac1753 | ||
|
|
dcaa4c037d | ||
|
|
9e4f805ed3 | ||
|
|
ad7faf6e81 | ||
|
|
855302ec60 | ||
|
|
f5f8fbc65e | ||
|
|
968c83db99 | ||
|
|
1e2f30c6c7 | ||
|
|
f120064fe3 | ||
|
|
aaff88d32f | ||
|
|
b8c3cc6cf8 | ||
|
|
ca4479252f | ||
|
|
d2485df64d | ||
|
|
6a489287ba | ||
|
|
6e7a61f5f2 | ||
|
|
20b8e8da86 | ||
|
|
63df092973 | ||
|
|
378643623b |
@@ -1,5 +0,0 @@
|
||||
# Игнорим данные, т.к. они не нужны для внутреннего содержимого этого контейнера
|
||||
data
|
||||
|
||||
# Vendor так же не нужен
|
||||
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 }}
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -1,15 +1,11 @@
|
||||
# IDEA
|
||||
/.idea
|
||||
# IDE files
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode
|
||||
|
||||
# Docker Compose file
|
||||
/docker-compose.yml
|
||||
/docker-compose.override.yml
|
||||
|
||||
# vendor
|
||||
# Go mod vendoring
|
||||
/vendor
|
||||
|
||||
# Cover output
|
||||
.cover
|
||||
|
||||
# Local config
|
||||
/config.yml
|
||||
# Local environment
|
||||
/docker-compose.yml
|
||||
/data
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# Предполагается, что между работой "build docker container" и этапом push
|
||||
# построенные docker images остаются статичными и никуда не пропадают
|
||||
#
|
||||
# В противном случае их нужно после каждого этапа билда пушить в registry
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- build_docker_image
|
||||
- push
|
||||
- cleanup
|
||||
|
||||
variables:
|
||||
CONTAINER_IMAGE: registry.ely.by/elyby/skinsystem
|
||||
|
||||
.golang_template: &setup_go_environment
|
||||
image: golang:1.9.0-alpine3.6
|
||||
before_script:
|
||||
- apk add --no-cache git
|
||||
- mkdir -p $GOPATH/src/$CI_PROJECT_NAMESPACE
|
||||
- cp -r $(pwd) $GOPATH/src/$CI_PROJECT_PATH
|
||||
- cd $GOPATH/src/$CI_PROJECT_PATH
|
||||
- go get -u github.com/golang/dep/cmd/dep
|
||||
- $GOPATH/bin/dep ensure
|
||||
|
||||
.docker_template: &setup_docker_environment
|
||||
image: docker:latest
|
||||
before_script:
|
||||
- docker login -u gitlab-ci -p $CI_JOB_TOKEN registry.ely.by
|
||||
- export TEMP_IMAGE_NAME="$CONTAINER_IMAGE:$CI_PIPELINE_ID"
|
||||
|
||||
test:
|
||||
<<: *setup_go_environment
|
||||
stage: test
|
||||
script:
|
||||
- ./script/coverage
|
||||
|
||||
build executable:
|
||||
<<: *setup_go_environment
|
||||
stage: build
|
||||
script:
|
||||
- export VERSION="${CI_COMMIT_TAG:-dev-$CI_COMMIT_REF_NAME-${CI_COMMIT_SHA:0:8}+build-$CI_JOB_ID}"
|
||||
- >
|
||||
env GOOS=linux
|
||||
go build
|
||||
-o $CI_PROJECT_DIR/minecraft-skinsystem
|
||||
-ldflags "-X ${CI_PROJECT_PATH}/bootstrap.version=${VERSION}"
|
||||
main.go
|
||||
artifacts:
|
||||
name: "${CI_JOB_STAGE} executable"
|
||||
paths:
|
||||
- $CI_PROJECT_DIR/minecraft-skinsystem
|
||||
expire_in: 1 day
|
||||
|
||||
build docker image:
|
||||
<<: *setup_docker_environment
|
||||
stage: build_docker_image
|
||||
script:
|
||||
- docker build -t $TEMP_IMAGE_NAME -f docker/Dockerfile .
|
||||
only:
|
||||
- tags
|
||||
- develop
|
||||
|
||||
push dev:
|
||||
<<: *setup_docker_environment
|
||||
stage: push
|
||||
variables:
|
||||
GIT_STRATEGY: none
|
||||
script:
|
||||
- export IMAGE_NAME="$CONTAINER_IMAGE:dev"
|
||||
- docker tag $TEMP_IMAGE_NAME $IMAGE_NAME
|
||||
- docker push $IMAGE_NAME
|
||||
only:
|
||||
- develop
|
||||
|
||||
push tag:
|
||||
<<: *setup_docker_environment
|
||||
stage: push
|
||||
variables:
|
||||
GIT_STRATEGY: none
|
||||
script:
|
||||
- export IMAGE_NAME="$CONTAINER_IMAGE:$CI_COMMIT_TAG"
|
||||
- export LATEST_IMAGE_NAME="$CONTAINER_IMAGE:latest"
|
||||
- docker tag $TEMP_IMAGE_NAME $IMAGE_NAME
|
||||
- docker tag $TEMP_IMAGE_NAME $LATEST_IMAGE_NAME
|
||||
- docker push $IMAGE_NAME
|
||||
- docker push $LATEST_IMAGE_NAME
|
||||
only:
|
||||
- tags
|
||||
|
||||
cleanup temp image:
|
||||
<<: *setup_docker_environment
|
||||
stage: cleanup
|
||||
when: always
|
||||
script:
|
||||
- docker rmi $TEMP_IMAGE_NAME || true
|
||||
196
CHANGELOG.md
Normal file
196
CHANGELOG.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Changelog
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased] - 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
|
||||
- GC for in-memory textures cache has not been initialized.
|
||||
|
||||
## [4.2.1] - 2019-05-06
|
||||
### Changed
|
||||
- Improved Keep-Alive settings for HTTP client used to perform requests to Mojang's APIs.
|
||||
- Mojang's textures queue now has static delay of 1 second after each iteration to prevent strange `429` errors.
|
||||
- Mojang's textures queue now caches even errored responses for signed textures to avoid `429` errors.
|
||||
- Mojang's textures queue now caches textures data for 70 seconds to avoid strange `429` errors.
|
||||
- Mojang's textures queue now doesn't log timeout errors.
|
||||
|
||||
### Fixed
|
||||
- Panic when Redis connection is broken.
|
||||
- Duplication of Redis connections pool for Mojang's textures queue.
|
||||
- Removed validation rules for `hash` field.
|
||||
|
||||
## [4.2.0] - 2019-05-02
|
||||
### Added
|
||||
- `CHANGELOG.md` file.
|
||||
- [#1](https://github.com/elyby/chrly/issues/1): Restored Mojang skins proxy.
|
||||
- New StatsD metrics:
|
||||
- Counters:
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.request`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit_nil`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queued`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_miss`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_hit`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.cache_hit`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request`
|
||||
- Gauges:
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size`
|
||||
- Timers:
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.result_time`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time`
|
||||
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request_time`
|
||||
|
||||
### Changed
|
||||
- Bumped Go version to 1.12.
|
||||
- Bumped Alpine version to 3.9.3.
|
||||
|
||||
### Fixed
|
||||
- `/textures` request no longer proxies request to Mojang in a case when there is no information about the skin,
|
||||
but there is a cape.
|
||||
- [#5](https://github.com/elyby/chrly/issues/5): Return Redis connection to the pool after commands are executed
|
||||
|
||||
### Removed
|
||||
- `hash` field from `/textures` response because the game doesn't use it and calculates hash by getting the filename
|
||||
from the textures link instead.
|
||||
- `hash` field from `POST /api/skins` endpoint.
|
||||
|
||||
[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
|
||||
189
Gopkg.lock
generated
189
Gopkg.lock
generated
@@ -1,189 +0,0 @@
|
||||
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
||||
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/assembla/cony"
|
||||
packages = ["."]
|
||||
revision = "dd62697b0adb9adfda8589520cb85f4cbc2361f1"
|
||||
version = "v0.3.2"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/certifi/gocertifi"
|
||||
packages = ["."]
|
||||
revision = "3fd9e1adb12b72d2f3f82191d49be9b93c69f67c"
|
||||
version = "2017.07.27"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/davecgh/go-spew"
|
||||
packages = ["spew"]
|
||||
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
|
||||
version = "v1.1.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/fsnotify/fsnotify"
|
||||
packages = ["."]
|
||||
revision = "629574ca2a5df945712d3079857300b5e4da0236"
|
||||
version = "v1.4.2"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/getsentry/raven-go"
|
||||
packages = ["."]
|
||||
revision = "d175f85701dfbf44cb0510114c9943e665e60907"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/golang/mock"
|
||||
packages = ["gomock"]
|
||||
revision = "13f360950a79f5864a972c786a10a50e44b69541"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/context"
|
||||
packages = ["."]
|
||||
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
|
||||
version = "v1.1"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/mux"
|
||||
packages = ["."]
|
||||
revision = "bcd8bc72b08df0f70df986b97f95590779502d31"
|
||||
version = "v1.4.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/hashicorp/hcl"
|
||||
packages = [".","hcl/ast","hcl/parser","hcl/scanner","hcl/strconv","hcl/token","json/parser","json/scanner","json/token"]
|
||||
revision = "8f6b1344a92ff8877cf24a5de9177bf7d0a2a187"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/inconshreveable/mousetrap"
|
||||
packages = ["."]
|
||||
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
|
||||
version = "v1.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/magiconair/properties"
|
||||
packages = ["."]
|
||||
revision = "be5ece7dd465ab0765a9682137865547526d1dfb"
|
||||
version = "v1.7.3"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/mediocregopher/radix.v2"
|
||||
packages = ["cluster","pool","redis","util"]
|
||||
revision = "d234cfb904a91daafa4e1f92599a893b349cc0c2"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/mitchellh/mapstructure"
|
||||
packages = ["."]
|
||||
revision = "d0303fe809921458f417bcf828397a65db30a7e4"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/mono83/slf"
|
||||
packages = [".","filters","params","rays","recievers","recievers/ansi","recievers/statsd","wd"]
|
||||
revision = "8188a95c8d6b74c43953abb38b8bd6fdbc412ff5"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/mono83/udpwriter"
|
||||
packages = ["."]
|
||||
revision = "a064bd7e3acfda563ea680b913b9ef24b7a73e15"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pelletier/go-buffruneio"
|
||||
packages = ["."]
|
||||
revision = "c37440a7cf42ac63b919c752ca73a85067e05992"
|
||||
version = "v0.2.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pelletier/go-toml"
|
||||
packages = ["."]
|
||||
revision = "5ccdfb18c776b740aecaf085c4d9a2779199c279"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pmezard/go-difflib"
|
||||
packages = ["difflib"]
|
||||
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/spf13/afero"
|
||||
packages = [".","mem"]
|
||||
revision = "ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/spf13/cast"
|
||||
packages = ["."]
|
||||
revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4"
|
||||
version = "v1.1.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/spf13/cobra"
|
||||
packages = ["."]
|
||||
revision = "3c0b56b677e04926dfa835a1b3f11cd4f62f076e"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/spf13/jwalterweatherman"
|
||||
packages = ["."]
|
||||
revision = "12bd96e66386c1960ab0f74ced1362f66f552f7b"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/spf13/pflag"
|
||||
packages = ["."]
|
||||
revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/spf13/viper"
|
||||
packages = ["."]
|
||||
revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7"
|
||||
version = "v1.0.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/streadway/amqp"
|
||||
packages = ["."]
|
||||
revision = "2cbfe40c9341ad63ba23e53013b3ddc7989d801c"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/stretchr/testify"
|
||||
packages = ["assert"]
|
||||
revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0"
|
||||
version = "v1.1.4"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/sys"
|
||||
packages = ["unix"]
|
||||
revision = "7ddbeae9ae08c6a06a59597f0c9edbc5ff2444ce"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/text"
|
||||
packages = ["internal/gen","internal/triegen","internal/ucd","transform","unicode/cldr","unicode/norm"]
|
||||
revision = "bd91bbf73e9a4a801adbfb97133c992678533126"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/h2non/gock.v1"
|
||||
packages = ["."]
|
||||
revision = "84d599244901620fb3eb96473eb9e50619f69b47"
|
||||
version = "v1.0.6"
|
||||
|
||||
[[projects]]
|
||||
branch = "v2"
|
||||
name = "gopkg.in/yaml.v2"
|
||||
packages = ["."]
|
||||
revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f"
|
||||
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "dd545fafc23f9b6429b5b679ad5c213c14c819f1e4ea381823acf338651122e1"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
38
Gopkg.toml
38
Gopkg.toml
@@ -1,38 +0,0 @@
|
||||
ignored = ["elyby/minecraft-skinsystem"]
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gorilla/mux"
|
||||
version = "1.4.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/mediocregopher/radix.v2"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/mono83/slf"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/spf13/cobra"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/spf13/viper"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/getsentry/raven-go"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/assembla/cony"
|
||||
version = "^0.3.2"
|
||||
|
||||
# Testing dependencies
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/stretchr/testify"
|
||||
version = "^1.1.4"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/golang/mock"
|
||||
version = "^1.0.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "gopkg.in/h2non/gock.v1"
|
||||
version = "^1.0.6"
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
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.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
497
README.md
497
README.md
@@ -1,74 +1,459 @@
|
||||
# Ely.by Minecraft Skinsystem
|
||||
# Chrly
|
||||
|
||||
Реализация API системы скинов для Minecraft v4.
|
||||
[![Written in Go][ico-lang]][link-go]
|
||||
[![Build Status][ico-build]][link-build]
|
||||
[![Coverage][ico-coverage]][link-coverage]
|
||||
[![Keep a Changelog][ico-changelog]](CHANGELOG.md)
|
||||
[![Software License][ico-license]](LICENSE)
|
||||
|
||||
## Config
|
||||
Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's
|
||||
skins system. It's packaged and distributed as a Docker image and can be downloaded from
|
||||
[Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can withstand heavy loads and is
|
||||
production ready.
|
||||
|
||||
Конфигурация может задаваться посредством любого из перечисленных форматов файлов: JSON, TOML, YAML, HCL и
|
||||
Java properties. Кроме того, параметры конфигурации могут перезаписываться доступными при запуске программы
|
||||
ENV переменными.
|
||||
## Installation
|
||||
|
||||
> **Заметка**: ENV переменные именуются как KEY.SUBKEY.SUBSUBKEY, т.е. все символы должны быть заглавными,
|
||||
а точки должны отделять уровень вложенности.
|
||||
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` and `CHRLY_SIGNING_KEY`
|
||||
environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
|
||||
below.
|
||||
|
||||
Пример файла конфигурации находится в [config.dist.yml](config.dist.yml). Внутри dist-файла есть комментарии,
|
||||
поясняющие назначение тех или иных параметров. Для работы его следует скопировать в локальный `config.yml`
|
||||
и отредактировать под свои нужды.
|
||||
```yml
|
||||
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
|
||||
CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
|
||||
|
||||
## Развёртывание
|
||||
redis:
|
||||
image: redis:4.0-32bit
|
||||
restart: always
|
||||
volumes:
|
||||
- ./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
|
||||
```
|
||||
|
||||
1. Скомпилировав и запустив бинарный файл, а также обеспечив ему доступ ко всем необходмым сервисам.
|
||||
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.
|
||||
|
||||
2. Используя Docker и docker-compose.
|
||||
### Config
|
||||
|
||||
*Первый случай не буду описывать, т.к. долго, мучительно и никто так делать не будет, я гарантирую это*,
|
||||
поэтому перейдём сразу ко второму.
|
||||
|
||||
Прежде всего необходимо установить [Docker](https://docs.docker.com/engine/installation/) и
|
||||
[docker-compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
Для запуска последней версии проекта достаточно скопировать содержимое файла
|
||||
[docker/docker-compose.prod.yml](docker/docker-compose.prod.yml) в файл `docker-compose.yml` непосредственно
|
||||
на месте установки, после чего ввести в консоль команду:
|
||||
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 `up`
|
||||
it again:
|
||||
|
||||
```sh
|
||||
docker-compose up -d app
|
||||
```
|
||||
|
||||
**Variables to adjust:**
|
||||
|
||||
<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. The `.png` postfix can be omitted.
|
||||
|
||||
#### `GET /skins/{username}.png`
|
||||
|
||||
This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll
|
||||
respond with the `301` redirect to that url. If the skin entry isn't found, it'll request textures information from
|
||||
Mojang's API and if it has a skin, than it'll return a `301` redirect to it.
|
||||
|
||||
#### `GET /cloaks/{username}.png`
|
||||
|
||||
It responds to requested `username` with a cape texture. If the cape entry isn't found, it'll request textures
|
||||
information from Mojang's API and if it has a cape, than it'll return a `301` redirect to it.
|
||||
|
||||
#### `GET /textures/{username}`
|
||||
|
||||
This endpoint forms response payloads as if it was the `textures`' property, but without base64 encoding. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"SKIN": {
|
||||
"url": "http://example.com/skin.png",
|
||||
"metadata": {
|
||||
"model": "slim"
|
||||
}
|
||||
},
|
||||
"CAPE": {
|
||||
"url": "http://example.com/cape.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has
|
||||
a textures property, than it'll return decoded contents.
|
||||
|
||||
That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined
|
||||
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, 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:
|
||||
|
||||
```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?"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent.
|
||||
|
||||
You can adjust URL to `/textures/signed/{username}?proxy=true` to obtain textures information for provided username
|
||||
from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in
|
||||
the example above.
|
||||
|
||||
#### `GET /skins?name={username}`
|
||||
|
||||
Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username
|
||||
placeholder wasn't used.
|
||||
|
||||
#### `GET /cloaks?name={username}`
|
||||
|
||||
Equivalent of the `GET /cloaks/{username}.png`, but constructed especially for old Minecraft versions, where username
|
||||
placeholder wasn't used.
|
||||
|
||||
### Records manipulating API
|
||||
|
||||
Each request to the internal API should be performed with the Bearer authorization header. Example curl request:
|
||||
|
||||
```sh
|
||||
curl -X POST -i http://chrly.domain.com/api/skins \
|
||||
-H "Authorization: Bearer Ym9zY236Ym9zY28="
|
||||
```
|
||||
|
||||
You can obtain token by executing `docker-compose run --rm app token`.
|
||||
|
||||
#### `POST /api/skins`
|
||||
|
||||
Endpoint allows you to create or update skin record for a username.
|
||||
|
||||
The request body must be encoded as `application/x-www-form-urlencoded`.
|
||||
|
||||
**Request params:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------------|--------|--------------------------------------------------------------------------------|
|
||||
| identityId | int | Unique record identifier. |
|
||||
| username | string | Username. Case insensitive. |
|
||||
| uuid | uuid | UUID of the user. |
|
||||
| skinId | int | Skin identifier. |
|
||||
| is1_8 | bool | Does the skin have the new format (64x64). |
|
||||
| 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. |
|
||||
|
||||
**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:
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": {
|
||||
"identityId": [
|
||||
"The identityId field must be numeric"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/skins/id:{identityId}`
|
||||
|
||||
Performs record removal by identity id. Request body is not required. On success you will receive `204` status code.
|
||||
On failure it'll be `404` with the json body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Cannot find record for requested user id"
|
||||
}
|
||||
```
|
||||
|
||||
#### `DELETE /api/skins/{username}`
|
||||
|
||||
Same endpoint as above but it removes record by identity's username. Have the same behavior, but in case of failure
|
||||
response will be:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Cannot find record for requested username"
|
||||
}
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
Then you must fork this repository. Now follow these steps:
|
||||
|
||||
```sh
|
||||
# Get the source code
|
||||
git clone https://github.com/elyby/chrly.git
|
||||
# Switch to the project folder
|
||||
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
|
||||
git checkout -b iss-123
|
||||
```
|
||||
|
||||
You only need to execute `go run main.go` to run the project, but without Redis database and a secret key it won't work
|
||||
for very long. You have to export `CHRLY_SECRET` environment variable globally or pass it via `env`:
|
||||
|
||||
```sh
|
||||
env CHRLY_SECRET=some_local_secret go run main.go serve
|
||||
```
|
||||
|
||||
Redis can be installed manually, but if you have [Docker installed](https://docs.docker.com/install/), you can run
|
||||
predefined docker-compose service. Simply execute the next commands:
|
||||
|
||||
```sh
|
||||
cp docker-compose.dev.yml docker-compose.yml
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Web-приложение, amqp worker и все сопутствующие сервисы будут автоматически запущены. Данные из контейнеров
|
||||
будут синхронизироваться в папку `data`.
|
||||
If your Redis instance isn't located at the `localhost`, you can change host by editing environment variable
|
||||
`STORAGE_REDIS_HOST`.
|
||||
|
||||
## Разработка
|
||||
After all of that `go run main.go serve` should successfully start the application.
|
||||
To run tests execute `go test ./...`.
|
||||
|
||||
Перво-наперво необходимо [установить последнюю версию Go](https://golang.org/doc/install) и сконфигурировать
|
||||
переменную окружения GOPATH, а также установить инструмент контроля версий [dep](https://github.com/golang/dep).
|
||||
[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
|
||||
|
||||
Затем можно склонировать репозиторий хитрым способом, чтобы удовлетворить все прекрасные особенности Go:
|
||||
|
||||
```sh
|
||||
# Сперва создадим подпапку для приватных Go проектов Ely.by
|
||||
mkdir -p $GOPATH/src/elyby
|
||||
# Затем непосредственно клинируем репозиторий туда, где его ожидает увидеть Go
|
||||
git clone git@gitlab.ely.by:elyby/minecraft-skinsystem.git $GOPATH/src/elyby/minecraft-skinsystem
|
||||
# Переходим в папку проекта
|
||||
cd $GOPATH/src/elyby/minecraft-skinsystem
|
||||
# Устанавливаем зависимости
|
||||
dep ensure
|
||||
```
|
||||
|
||||
Чтобы запустить проект достаточно написать `go run main.go`, но без файла конфигурации и Redis
|
||||
программа долго не проработает. Поэтому сперва копируем `config.dist.yml` в `config.yml` и, при необходимости,
|
||||
затачиваем его под себя.
|
||||
|
||||
Redis можно установить в систему самостоятельно, но гораздо удобнее воспользоваться готовыми сервисами,
|
||||
описанными в [docker/docker-compose.dev.yml](docker/docker-compose.dev.yml). Для этого просто копируем
|
||||
`docker-compose.dev.yml` и поднимаем сервисы:
|
||||
|
||||
```sh
|
||||
cp docker/docker-compose.dev.yml docker-compose.yml
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
После этого `go run main.go serve` должен запустить web-сервер без дополнительной модификации файла конфигурации.
|
||||
[link-go]: https://golang.org
|
||||
[link-build]: https://github.com/elyby/chrly/actions
|
||||
[link-coverage]: https://codecov.io/gh/elyby/chrly
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
Id string
|
||||
Secret string
|
||||
Scopes []string
|
||||
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
config *Config
|
||||
}
|
||||
|
||||
func (config *Config) GetToken() (*Token, error) {
|
||||
form := url.Values{}
|
||||
form.Add("client_id", config.Id)
|
||||
form.Add("client_secret", config.Secret)
|
||||
form.Add("grant_type", "client_credentials")
|
||||
form.Add("scope", strings.Join(config.Scopes, ","))
|
||||
|
||||
response, err := config.getHttpClient().Post(config.getTokenUrl(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var result *Token
|
||||
responseError := handleResponse(response)
|
||||
if responseError != nil {
|
||||
return nil, responseError
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
unmarshalError := json.Unmarshal(body, &result)
|
||||
if unmarshalError != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.config = config
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (config *Config) getTokenUrl() string {
|
||||
return concatenateHostAndPath(config.Addr, "/api/oauth2/v1/token")
|
||||
}
|
||||
|
||||
func (config *Config) getHttpClient() *http.Client {
|
||||
if config.Client == nil {
|
||||
config.Client = &http.Client{}
|
||||
}
|
||||
|
||||
return config.Client
|
||||
}
|
||||
|
||||
type AccountInfoResponse struct {
|
||||
Id int `json:"id"`
|
||||
Uuid string `json:"uuid"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (token *Token) AccountInfo(attribute string, value string) (*AccountInfoResponse, error) {
|
||||
request := token.newRequest("GET", token.accountInfoUrl(), nil)
|
||||
|
||||
query := request.URL.Query()
|
||||
query.Add(attribute, value)
|
||||
request.URL.RawQuery = query.Encode()
|
||||
|
||||
response, err := token.config.Client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
var info *AccountInfoResponse
|
||||
|
||||
responseError := handleResponse(response)
|
||||
if responseError != nil {
|
||||
return nil, responseError
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(response.Body)
|
||||
json.Unmarshal(body, &info)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (token *Token) accountInfoUrl() string {
|
||||
return concatenateHostAndPath(token.config.Addr, "/api/internal/accounts/info")
|
||||
}
|
||||
|
||||
func (token *Token) newRequest(method string, urlStr string, body io.Reader) *http.Request {
|
||||
request, err := http.NewRequest(method, urlStr, body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
request.Header.Add("Authorization", "Bearer " + token.AccessToken)
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
func concatenateHostAndPath(host string, pathToJoin string) string {
|
||||
u, _ := url.Parse(host)
|
||||
u.Path = path.Join(u.Path, pathToJoin)
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
type UnauthorizedResponse struct {}
|
||||
|
||||
func (err UnauthorizedResponse) Error() string {
|
||||
return "Unauthorized response"
|
||||
}
|
||||
|
||||
type ForbiddenResponse struct {}
|
||||
|
||||
func (err ForbiddenResponse) Error() string {
|
||||
return "Forbidden response"
|
||||
}
|
||||
|
||||
type NotFoundResponse struct {}
|
||||
|
||||
func (err NotFoundResponse) Error() string {
|
||||
return "Not found"
|
||||
}
|
||||
|
||||
type NotSuccessResponse struct {
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
func (err NotSuccessResponse) Error() string {
|
||||
return fmt.Sprintf("Response code is \"%d\"", err.StatusCode)
|
||||
}
|
||||
|
||||
func handleResponse(response *http.Response) error {
|
||||
switch status := response.StatusCode; status {
|
||||
case 200:
|
||||
return nil
|
||||
case 401:
|
||||
return &UnauthorizedResponse{}
|
||||
case 403:
|
||||
return &ForbiddenResponse{}
|
||||
case 404:
|
||||
return &NotFoundResponse{}
|
||||
default:
|
||||
return &NotSuccessResponse{status}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
"gopkg.in/h2non/gock.v1"
|
||||
)
|
||||
|
||||
func TestConfig_GetToken(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://account.ely.by").
|
||||
Post("/api/oauth2/v1/token").
|
||||
Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")).
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"access_token": "mocked-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
config := &Config{
|
||||
Addr: "https://account.ely.by",
|
||||
Id: "mock-id",
|
||||
Secret: "mock-secret",
|
||||
Scopes: []string{"scope1", "scope2"},
|
||||
Client: client,
|
||||
}
|
||||
|
||||
result, err := config.GetToken()
|
||||
if assert.NoError(err) {
|
||||
assert.Equal("mocked-token", result.AccessToken)
|
||||
assert.Equal("Bearer", result.TokenType)
|
||||
assert.Equal(86400, result.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToken_AccountInfo(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
// To test valid behavior
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mock-token").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": 1,
|
||||
"uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3",
|
||||
"username": "dummy",
|
||||
"email": "dummy@ely.by",
|
||||
})
|
||||
|
||||
// To test behavior on invalid or expired token
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mock-token").
|
||||
Reply(401).
|
||||
JSON(map[string]interface{}{
|
||||
"name": "Unauthorized",
|
||||
"message": "Incorrect token",
|
||||
"code": 0,
|
||||
"status": 401,
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
token := &Token{
|
||||
AccessToken: "mock-token",
|
||||
config: &Config{
|
||||
Addr: "https://account.ely.by",
|
||||
Client: client,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := token.AccountInfo("id", "1")
|
||||
if assert.NoError(err) {
|
||||
assert.Equal(1, result.Id)
|
||||
assert.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", result.Uuid)
|
||||
assert.Equal("dummy", result.Username)
|
||||
assert.Equal("dummy@ely.by", result.Email)
|
||||
}
|
||||
|
||||
result2, err2 := token.AccountInfo("id", "1")
|
||||
assert.Nil(result2)
|
||||
assert.Error(err2)
|
||||
assert.IsType(&UnauthorizedResponse{}, err2)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package accounts
|
||||
|
||||
type AutoRefresh struct {
|
||||
token *Token
|
||||
config *Config
|
||||
repeatsCount int
|
||||
}
|
||||
|
||||
const repeatsLimit = 3
|
||||
|
||||
func (config *Config) GetTokenWithAutoRefresh() *AutoRefresh {
|
||||
return &AutoRefresh{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (refresher *AutoRefresh) AccountInfo(attribute string, value string) (*AccountInfoResponse, error) {
|
||||
defer refresher.resetRepeatsCount()
|
||||
|
||||
apiToken, err := refresher.getToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := apiToken.AccountInfo(attribute, value)
|
||||
if err != nil {
|
||||
_, isTokenExpire := err.(*UnauthorizedResponse)
|
||||
if !isTokenExpire || refresher.repeatsCount >= repeatsLimit - 1 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refresher.repeatsCount++
|
||||
refresher.token = nil
|
||||
|
||||
return refresher.AccountInfo(attribute, value)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (refresher *AutoRefresh) getToken() (*Token, error) {
|
||||
if refresher.token == nil {
|
||||
newToken, err := refresher.config.GetToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refresher.token = newToken
|
||||
}
|
||||
|
||||
return refresher.token, nil
|
||||
}
|
||||
|
||||
func (refresher *AutoRefresh) resetRepeatsCount() {
|
||||
refresher.repeatsCount = 0
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
"gopkg.in/h2non/gock.v1"
|
||||
)
|
||||
|
||||
var config = &Config{
|
||||
Addr: "https://account.ely.by",
|
||||
Id: "mock-id",
|
||||
Secret: "mock-secret",
|
||||
Scopes: []string{"scope1", "scope2"},
|
||||
}
|
||||
|
||||
func TestConfig_GetTokenWithAutoRefresh(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
testConfig := &Config{}
|
||||
*testConfig = *config
|
||||
|
||||
result := testConfig.GetTokenWithAutoRefresh()
|
||||
assert.Equal(testConfig, result.config)
|
||||
}
|
||||
|
||||
func TestAutoRefresh_AccountInfo(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://account.ely.by").
|
||||
Post("/api/oauth2/v1/token").
|
||||
Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")).
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"access_token": "mocked-token",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
Times(2).
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mocked-token").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": 1,
|
||||
"uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3",
|
||||
"username": "dummy",
|
||||
"email": "dummy@ely.by",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
testConfig := &Config{}
|
||||
*testConfig = *config
|
||||
testConfig.Client = client
|
||||
|
||||
autoRefresher := testConfig.GetTokenWithAutoRefresh()
|
||||
result, err := autoRefresher.AccountInfo("id", "1")
|
||||
if assert.NoError(err) {
|
||||
assert.Equal(1, result.Id)
|
||||
assert.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", result.Uuid)
|
||||
assert.Equal("dummy", result.Username)
|
||||
assert.Equal("dummy@ely.by", result.Email)
|
||||
}
|
||||
|
||||
result2, err2 := autoRefresher.AccountInfo("id", "1")
|
||||
if assert.NoError(err2) {
|
||||
assert.Equal(result, result2, "Results should still be same without token refreshing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRefresh_AccountInfo2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://account.ely.by").
|
||||
Post("/api/oauth2/v1/token").
|
||||
Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")).
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"access_token": "mocked-token-1",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mocked-token-1").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": 1,
|
||||
"uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3",
|
||||
"username": "dummy",
|
||||
"email": "dummy@ely.by",
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mocked-token-1").
|
||||
Reply(401).
|
||||
JSON(map[string]interface{}{
|
||||
"name": "Unauthorized",
|
||||
"message": "Incorrect token",
|
||||
"code": 0,
|
||||
"status": 401,
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Post("/api/oauth2/v1/token").
|
||||
Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")).
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"access_token": "mocked-token-2",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mocked-token-2").
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"id": 1,
|
||||
"uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3",
|
||||
"username": "dummy",
|
||||
"email": "dummy@ely.by",
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
testConfig := &Config{}
|
||||
*testConfig = *config
|
||||
testConfig.Client = client
|
||||
|
||||
autoRefresher := testConfig.GetTokenWithAutoRefresh()
|
||||
result, err := autoRefresher.AccountInfo("id", "1")
|
||||
if assert.NoError(err) {
|
||||
assert.Equal(1, result.Id)
|
||||
assert.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", result.Uuid)
|
||||
assert.Equal("dummy", result.Username)
|
||||
assert.Equal("dummy@ely.by", result.Email)
|
||||
}
|
||||
|
||||
result2, err2 := autoRefresher.AccountInfo("id", "1")
|
||||
if assert.NoError(err2) {
|
||||
assert.Equal(result, result2, "Results should still be same with refreshed token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRefresh_AccountInfo3(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://account.ely.by").
|
||||
Post("/api/oauth2/v1/token").
|
||||
Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")).
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"access_token": "mocked-token-1",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mocked-token-1").
|
||||
Reply(404).
|
||||
JSON(map[string]interface{}{
|
||||
"name": "Not Found",
|
||||
"message": "Page not found.",
|
||||
"code": 0,
|
||||
"status": 404,
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
testConfig := &Config{}
|
||||
*testConfig = *config
|
||||
testConfig.Client = client
|
||||
|
||||
autoRefresher := testConfig.GetTokenWithAutoRefresh()
|
||||
result, err := autoRefresher.AccountInfo("id", "1")
|
||||
assert.Nil(result)
|
||||
assert.Error(err)
|
||||
assert.IsType(&NotFoundResponse{}, err)
|
||||
}
|
||||
|
||||
func TestAutoRefresh_AccountInfo4(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
defer gock.Off()
|
||||
gock.New("https://account.ely.by").
|
||||
Post("/api/oauth2/v1/token").
|
||||
Times(3).
|
||||
Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")).
|
||||
Reply(200).
|
||||
JSON(map[string]interface{}{
|
||||
"access_token": "mocked-token-1",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
})
|
||||
|
||||
gock.New("https://account.ely.by").
|
||||
Get("/api/internal/accounts/info").
|
||||
Times(3).
|
||||
MatchParam("id", "1").
|
||||
MatchHeader("Authorization", "Bearer mocked-token-1").
|
||||
Reply(401).
|
||||
JSON(map[string]interface{}{
|
||||
"name": "Unauthorized",
|
||||
"message": "Incorrect token",
|
||||
"code": 0,
|
||||
"status": 401,
|
||||
})
|
||||
|
||||
client := &http.Client{}
|
||||
gock.InterceptClient(client)
|
||||
|
||||
testConfig := &Config{}
|
||||
*testConfig = *config
|
||||
testConfig.Client = client
|
||||
|
||||
autoRefresher := testConfig.GetTokenWithAutoRefresh()
|
||||
result, err := autoRefresher.AccountInfo("id", "1")
|
||||
assert.Nil(result)
|
||||
assert.Error(err)
|
||||
if !assert.IsType(&UnauthorizedResponse{}, err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"github.com/assembla/cony"
|
||||
"github.com/getsentry/raven-go"
|
||||
"github.com/mono83/slf/rays"
|
||||
"github.com/mono83/slf/recievers/statsd"
|
||||
"github.com/mono83/slf/recievers/writer"
|
||||
"github.com/mono83/slf/wd"
|
||||
|
||||
"elyby/minecraft-skinsystem/logger/receivers/sentry"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type RabbitMQConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
Host string
|
||||
Port int
|
||||
Vhost string
|
||||
}
|
||||
|
||||
func CreateRabbitMQClient(config *RabbitMQConfig) *cony.Client {
|
||||
addr := fmt.Sprintf(
|
||||
"amqp://%s:%s@%s:%d/%s",
|
||||
config.Username,
|
||||
config.Password,
|
||||
config.Host,
|
||||
config.Port,
|
||||
url.PathEscape(config.Vhost),
|
||||
)
|
||||
|
||||
client := cony.NewClient(cony.URL(addr), cony.Backoff(cony.DefaultBackoff))
|
||||
|
||||
return client
|
||||
}
|
||||
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"]
|
||||
@@ -1,67 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"elyby/minecraft-skinsystem/api/accounts"
|
||||
"elyby/minecraft-skinsystem/bootstrap"
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
"elyby/minecraft-skinsystem/worker"
|
||||
)
|
||||
|
||||
var amqpWorkerCmd = &cobra.Command{
|
||||
Use: "amqp-worker",
|
||||
Short: "Launches a worker which listens to events and processes them",
|
||||
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")
|
||||
skinsRepo, err := storageFactory.CreateFactory("redis").CreateSkinsRepository()
|
||||
if err != nil {
|
||||
logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err))
|
||||
return
|
||||
}
|
||||
logger.Info("Skins repository successfully initialized")
|
||||
|
||||
logger.Info("Creating AMQP client")
|
||||
amqpClient := bootstrap.CreateRabbitMQClient(&bootstrap.RabbitMQConfig{
|
||||
Host: viper.GetString("amqp.host"),
|
||||
Port: viper.GetInt("amqp.port"),
|
||||
Username: viper.GetString("amqp.username"),
|
||||
Password: viper.GetString("amqp.password"),
|
||||
Vhost: viper.GetString("amqp.vhost"),
|
||||
})
|
||||
|
||||
accountsApi := (&accounts.Config{
|
||||
Addr: viper.GetString("api.accounts.host"),
|
||||
Id: viper.GetString("api.accounts.id"),
|
||||
Secret: viper.GetString("api.accounts.secret"),
|
||||
Scopes: viper.GetStringSlice("api.accounts.scopes"),
|
||||
}).GetTokenWithAutoRefresh()
|
||||
|
||||
services := &worker.Services{
|
||||
Logger: logger,
|
||||
AmqpClient: amqpClient,
|
||||
SkinsRepo: skinsRepo,
|
||||
AccountsAPI: accountsApi,
|
||||
}
|
||||
|
||||
if err := services.Run(); err != nil {
|
||||
logger.Error(fmt.Sprintf("Cannot initialize worker: %+v", err))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(amqpWorkerCmd)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
46
cmd/root.go
46
cmd/root.go
@@ -1,46 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var cfgFile string
|
||||
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "",
|
||||
Short: "Nothing here",
|
||||
}
|
||||
|
||||
// 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)
|
||||
RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.test.yaml)")
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
if cfgFile != "" {
|
||||
viper.SetConfigFile(cfgFile)
|
||||
} else {
|
||||
viper.SetConfigName("config")
|
||||
viper.AddConfigPath("/etc/minecraft-skinsystem")
|
||||
viper.AddConfigPath(".")
|
||||
}
|
||||
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err == nil {
|
||||
fmt.Println("Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
}
|
||||
58
cmd/serve.go
58
cmd/serve.go
@@ -1,58 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"elyby/minecraft-skinsystem/bootstrap"
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
"elyby/minecraft-skinsystem/http"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Runs the system server skins",
|
||||
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")
|
||||
skinsRepo, err := storageFactory.CreateFactory("redis").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")
|
||||
capesRepo, err := storageFactory.CreateFactory("filesystem").CreateCapesRepository()
|
||||
if err != nil {
|
||||
logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err))
|
||||
return
|
||||
}
|
||||
logger.Info("Capes repository successfully initialized")
|
||||
|
||||
cfg := &http.Config{
|
||||
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
|
||||
SkinsRepo: skinsRepo,
|
||||
CapesRepo: capesRepo,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
if err := cfg.Run(); err != nil {
|
||||
logger.Error(fmt.Sprintf("Error in main(): %v", err))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"elyby/minecraft-skinsystem/bootstrap"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show the Minecraft Skinsystem version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("Version: %s\n", bootstrap.GetVersion())
|
||||
fmt.Printf("Go version: %s\n", runtime.Version())
|
||||
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
# Main server configuration. Actually you don't want to change it,
|
||||
# but you able to change host or port, that will be used by serve command
|
||||
server:
|
||||
host: localhost
|
||||
port: 80
|
||||
|
||||
# Worker listen to AMQP events, so it should know how to connect to any
|
||||
# AMQP provider (actually RabbitMQ). You should not escape any vhost
|
||||
# characters, 'cause it will be done by application automatically
|
||||
amqp:
|
||||
host: localhost
|
||||
port: 5672
|
||||
username: amqp-user
|
||||
password: amqp-password
|
||||
vhost: /
|
||||
|
||||
# Both of web or worker depends on storage.
|
||||
storage:
|
||||
# For now app require Redis and don't support any other backends to store
|
||||
# skins, but in the future we can have more backends. Poll size tune amount
|
||||
# of connections to the redis. It's not recommended to set it less then 2
|
||||
# because it will lead to panic on high load.
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
poolSize: 10
|
||||
|
||||
# Filesystem storage used to store capes. basePath specify absolute or relative
|
||||
# path to storage and capesDirName specify which folder in this base path will
|
||||
# be used to search capes.
|
||||
filesystem:
|
||||
basePath: data
|
||||
capesDirName: capes
|
||||
|
||||
# Accounts Ely.by internal API will be used in cases, when by some reasons
|
||||
# information about user will be unavailable in the app storage.
|
||||
api:
|
||||
accounts:
|
||||
host: https://account.ely.by
|
||||
id: app-id
|
||||
secret: secret
|
||||
scopes:
|
||||
- internal_account_info
|
||||
|
||||
# StatsD can be used to collect metrics
|
||||
# statsd:
|
||||
# addr: localhost:3746
|
||||
|
||||
# Sentry can be used to collect app errors
|
||||
# sentry:
|
||||
# dsn: "https://public:private@your.sentry.io/1"
|
||||
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,34 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"elyby/minecraft-skinsystem/interfaces"
|
||||
)
|
||||
|
||||
type StorageFactory struct {
|
||||
Config *viper.Viper
|
||||
}
|
||||
|
||||
type RepositoriesCreator interface {
|
||||
CreateSkinsRepository() (interfaces.SkinsRepository, error)
|
||||
CreateCapesRepository() (interfaces.CapesRepository, 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,59 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"elyby/minecraft-skinsystem/interfaces"
|
||||
"elyby/minecraft-skinsystem/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) 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
|
||||
}
|
||||
198
db/redis.go
198
db/redis.go
@@ -1,198 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mediocregopher/radix.v2/pool"
|
||||
"github.com/mediocregopher/radix.v2/redis"
|
||||
"github.com/mediocregopher/radix.v2/util"
|
||||
|
||||
"elyby/minecraft-skinsystem/interfaces"
|
||||
"elyby/minecraft-skinsystem/model"
|
||||
)
|
||||
|
||||
type RedisFactory struct {
|
||||
Host string
|
||||
Port int
|
||||
PoolSize int
|
||||
connection util.Cmder
|
||||
}
|
||||
|
||||
func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
|
||||
connection, err := f.getConnection()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &redisDb{connection}, nil
|
||||
}
|
||||
|
||||
func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
|
||||
panic("capes repository not supported for this storage type")
|
||||
}
|
||||
|
||||
func (f RedisFactory) getConnection() (util.Cmder, error) {
|
||||
if f.connection == 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 := createConnection(addr, f.PoolSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f.connection = conn
|
||||
|
||||
go func() {
|
||||
period := 5
|
||||
for {
|
||||
time.Sleep(time.Duration(period) * time.Second)
|
||||
resp := f.connection.Cmd("PING")
|
||||
if resp.Err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("Redis not pinged. Try to reconnect")
|
||||
conn, err := createConnection(addr, f.PoolSize)
|
||||
if err != nil {
|
||||
log.Printf("Cannot reconnect to redis: %v\n", err)
|
||||
log.Printf("Waiting %d seconds to retry\n", period)
|
||||
continue
|
||||
}
|
||||
|
||||
f.connection = conn
|
||||
log.Println("Reconnected")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return f.connection, nil
|
||||
}
|
||||
|
||||
func createConnection(addr string, poolSize int) (util.Cmder, error) {
|
||||
if poolSize > 1 {
|
||||
return pool.New("tcp", addr, poolSize)
|
||||
} else {
|
||||
return redis.Dial("tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
type redisDb struct {
|
||||
conn util.Cmder
|
||||
}
|
||||
|
||||
const accountIdToUsernameKey string = "hash:username-to-account-id"
|
||||
|
||||
func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
|
||||
if username == "" {
|
||||
return nil, &SkinNotFoundError{username}
|
||||
}
|
||||
|
||||
redisKey := buildKey(username)
|
||||
response := db.conn.Cmd("GET", redisKey)
|
||||
if response.IsType(redis.Nil) {
|
||||
return nil, &SkinNotFoundError{username}
|
||||
}
|
||||
|
||||
encodedResult, err := response.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := zlibDecode(encodedResult)
|
||||
if err != nil {
|
||||
log.Println("Cannot uncompress zlib for key " + redisKey) // TODO: replace with valid error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var skin *model.Skin
|
||||
err = json.Unmarshal(result, &skin)
|
||||
if err != nil {
|
||||
log.Println("Cannot decode record data for key" + redisKey) // TODO: replace with valid error
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
skin.OldUsername = skin.Username
|
||||
|
||||
return skin, nil
|
||||
}
|
||||
|
||||
func (db *redisDb) FindByUserId(id int) (*model.Skin, error) {
|
||||
response := db.conn.Cmd("HGET", accountIdToUsernameKey, id)
|
||||
if response.IsType(redis.Nil) {
|
||||
return nil, SkinNotFoundError{"unknown"}
|
||||
}
|
||||
|
||||
username, _ := response.Str()
|
||||
|
||||
return db.FindByUsername(username)
|
||||
}
|
||||
|
||||
func (db *redisDb) Save(skin *model.Skin) error {
|
||||
conn := db.conn
|
||||
if poolConn, isPool := conn.(*pool.Pool); isPool {
|
||||
conn, _ = poolConn.Get()
|
||||
}
|
||||
|
||||
conn.Cmd("MULTI")
|
||||
|
||||
// Если пользователь сменил ник, то мы должны удать его ключ
|
||||
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
|
||||
conn.Cmd("DEL", buildKey(skin.OldUsername))
|
||||
}
|
||||
|
||||
// Если это новая запись или если пользователь сменил ник, то обновляем значение в хэш-таблице
|
||||
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
|
||||
conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username)
|
||||
}
|
||||
|
||||
str, _ := json.Marshal(skin)
|
||||
conn.Cmd("SET", buildKey(skin.Username), zlibEncode(str))
|
||||
|
||||
conn.Cmd("EXEC")
|
||||
|
||||
skin.OldUsername = skin.Username
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildKey(username string) string {
|
||||
return "username:" + strings.ToLower(username)
|
||||
}
|
||||
|
||||
//noinspection GoUnusedFunction
|
||||
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,13 +0,0 @@
|
||||
FROM alpine:3.6
|
||||
|
||||
RUN apk --update add ca-certificates \
|
||||
&& update-ca-certificates \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
COPY docker/docker-entrypoint.sh /usr/local/bin/
|
||||
COPY docker/config.dist.yml /usr/local/etc/minecraft-skinsystem/
|
||||
|
||||
COPY minecraft-skinsystem /usr/local/bin/
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["serve"]
|
||||
@@ -1,51 +0,0 @@
|
||||
# Main server configuration. Actually you don't want to change it,
|
||||
# but you able to change host or port, that will be used by serve command
|
||||
server:
|
||||
host: # leave host empty to allow Docker publish port
|
||||
port: 80
|
||||
|
||||
# Worker listen to AMQP events, so it should know how to connect to any
|
||||
# AMQP provider (actually RabbitMQ). You should not escape any vhost
|
||||
# characters, 'cause it will be done by application automatically
|
||||
amqp:
|
||||
host: rabbitmq
|
||||
port: 5672
|
||||
username: minecraft-skinsystem-app
|
||||
password: minecraft-skinsystem-app-password
|
||||
vhost: /
|
||||
|
||||
# Both of web or worker depends on storage.
|
||||
storage:
|
||||
# For now app require Redis and don't support any other backends to store
|
||||
# skins, but in the future we can have more backends. Poll size tune amount
|
||||
# of connections to the redis. It's not recommended to set it less then 2
|
||||
# because it will lead to panic on high load.
|
||||
redis:
|
||||
host: redis
|
||||
port: 6379
|
||||
poolSize: 10
|
||||
|
||||
# Filesystem storage used to store capes. basePath specify absolute or relative
|
||||
# path to storage and capesDirName specify which folder in this base path will
|
||||
# be used to search capes.
|
||||
filesystem:
|
||||
basePath: /data
|
||||
capesDirName: capes
|
||||
|
||||
# Accounts Ely.by internal API will be used in cases, when by some reasons
|
||||
# information about user will be unavailable in the app storage.
|
||||
api:
|
||||
accounts:
|
||||
host: https://account.ely.by
|
||||
id: app-id
|
||||
secret: secret
|
||||
scopes:
|
||||
- internal_account_info
|
||||
|
||||
# StatsD can be used to collect metrics
|
||||
# statsd:
|
||||
# addr: localhost:3746
|
||||
|
||||
# Sentry can be used to collect app errors
|
||||
# sentry:
|
||||
# dsn: https://public:private@your.sentry.io/1
|
||||
@@ -1,46 +0,0 @@
|
||||
# This compose file contains necessary docker-compose config to quick start
|
||||
# services required by app. Ports published to host.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Clone this file as docker-compose.yml:
|
||||
# cp docker/docker-compose.dev.yml docker-compose.yml
|
||||
#
|
||||
# 2. If necessary, then you can fix configuration to your environment.
|
||||
# Then start all services:
|
||||
# docker-compose up -d
|
||||
#
|
||||
# 3. Pass to the project configuration links to this services:
|
||||
# amqp:
|
||||
# host: localhost
|
||||
# port: 5672
|
||||
# username: ely
|
||||
# password: ely
|
||||
# vhost: /ely
|
||||
#
|
||||
# storage:
|
||||
# redis:
|
||||
# host: localhost
|
||||
# port: 6379
|
||||
# poolSize: 10
|
||||
#
|
||||
# 4. After job is done all services can be stopped:
|
||||
# docker-compose stop
|
||||
|
||||
version: '2'
|
||||
services:
|
||||
redis:
|
||||
image: redis:3.2-32bit
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.6-management-alpine
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "ely"
|
||||
RABBITMQ_DEFAULT_PASS: "ely"
|
||||
RABBITMQ_DEFAULT_VHOST: "/ely"
|
||||
@@ -1,36 +0,0 @@
|
||||
version: '2'
|
||||
services:
|
||||
web:
|
||||
image: registry.ely.by/elyby/skinsystem:latest
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
links:
|
||||
- redis
|
||||
volumes:
|
||||
- ./data/capes:/data/capes
|
||||
- ./config/minecraft-skinsystem:/etc/minecraft-skinsystem
|
||||
|
||||
worker:
|
||||
image: registry.ely.by/elyby/skinsystem:latest
|
||||
restart: always
|
||||
links:
|
||||
- redis
|
||||
- rabbitmq
|
||||
command: ["amqp-worker"]
|
||||
volumes:
|
||||
- ./config/minecraft-skinsystem:/etc/minecraft-skinsystem
|
||||
|
||||
redis:
|
||||
image: redis:3.2-32bit # 32-bit version used to decrease memory usage
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:3.6-alpine
|
||||
restart: always
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: minecraft-skinsystem-app
|
||||
RABBITMQ_DEFAULT_PASS: minecraft-skinsystem-app-password
|
||||
RABBITMQ_DEFAULT_VHOST: /
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CONFIG="/etc/minecraft-skinsystem/config.yml"
|
||||
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
mkdir -p $(dirname "${CONFIG}")
|
||||
cp /usr/local/etc/minecraft-skinsystem/config.dist.yml "$CONFIG"
|
||||
fi
|
||||
|
||||
if [ "$1" = "serve" ] || [ "$1" = "amqp-worker" ]; then
|
||||
set -- minecraft-skinsystem "$@"
|
||||
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=
|
||||
38
http/cape.go
38
http/cape.go
@@ -1,38 +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 {
|
||||
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
|
||||
return
|
||||
}
|
||||
|
||||
request.Header.Set("Content-Type", "image/png")
|
||||
io.Copy(response, rec.File)
|
||||
}
|
||||
|
||||
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,138 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
"elyby/minecraft-skinsystem/model"
|
||||
)
|
||||
|
||||
func TestConfig_Cape(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, _, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
cape := createCape()
|
||||
|
||||
capesRepo.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{
|
||||
File: bytes.NewReader(cape),
|
||||
}, nil)
|
||||
wd.EXPECT().IncCounter("capes.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/mocked_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(200, resp.StatusCode)
|
||||
responseData, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(cape, responseData)
|
||||
assert.Equal("image/png", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestConfig_Cape2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, _, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
capesRepo.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"})
|
||||
wd.EXPECT().IncCounter("capes.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/notch", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_CapeGET(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, _, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
cape := createCape()
|
||||
|
||||
capesRepo.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{
|
||||
File: bytes.NewReader(cape),
|
||||
}, nil)
|
||||
wd.EXPECT().IncCounter("capes.request", int64(1)).Times(0)
|
||||
wd.EXPECT().IncCounter("capes.get_request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=mocked_username", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(200, resp.StatusCode)
|
||||
responseData, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(cape, responseData)
|
||||
assert.Equal("image/png", resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
func TestConfig_CapeGET2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, _, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
capesRepo.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"})
|
||||
wd.EXPECT().IncCounter("capes.request", int64(1)).Times(0)
|
||||
wd.EXPECT().IncCounter("capes.get_request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=notch", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_CapeGET3(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/?name=notch", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
(&Config{}).CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://skinsystem.ely.by/cloaks?name=notch", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
27
http/face.go
27
http/face.go
@@ -1,27 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
const defaultHash = "default"
|
||||
|
||||
func (cfg *Config) Face(response http.ResponseWriter, request *http.Request) {
|
||||
cfg.Logger.IncCounter("faces.request", 1)
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
rec, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
var hash string
|
||||
if err != nil || rec.SkinId == 0 {
|
||||
hash = defaultHash
|
||||
} else {
|
||||
hash = rec.Hash
|
||||
}
|
||||
|
||||
http.Redirect(response, request, buildElyUrl(buildFaceUrl(hash)), 301)
|
||||
}
|
||||
|
||||
func buildFaceUrl(hash string) string {
|
||||
return "/minecraft/skin_buffer/faces/" + hash + ".png"
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
)
|
||||
|
||||
func TestConfig_Face(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
|
||||
wd.EXPECT().IncCounter("faces.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user/face.png", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://ely.by/minecraft/skin_buffer/faces/55d2a8848764f5ff04012cdb093458bd.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_Face2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{"mock_user"})
|
||||
wd.EXPECT().IncCounter("faces.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user/face.png", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://ely.by/minecraft/skin_buffer/faces/default.png", resp.Header.Get("Location"))
|
||||
}
|
||||
91
http/http.go
91
http/http.go
@@ -1,91 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mono83/slf/wd"
|
||||
|
||||
"elyby/minecraft-skinsystem/interfaces"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenSpec string
|
||||
|
||||
SkinsRepo interfaces.SkinsRepository
|
||||
CapesRepo interfaces.CapesRepository
|
||||
Logger wd.Watchdog
|
||||
}
|
||||
|
||||
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")
|
||||
router.HandleFunc("/skins/{username}/face", cfg.Face).Methods("GET")
|
||||
router.HandleFunc("/skins/{username}/face.png", cfg.Face).Methods("GET")
|
||||
// Legacy
|
||||
router.HandleFunc("/skins", cfg.SkinGET).Methods("GET")
|
||||
router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET")
|
||||
// 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 buildElyUrl(route string) string {
|
||||
prefix := "http://ely.by"
|
||||
if !strings.HasPrefix(route, prefix) {
|
||||
route = prefix + route
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
func waitForSignal() os.Signal {
|
||||
ch := make(chan os.Signal)
|
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
return <-ch
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
|
||||
"elyby/minecraft-skinsystem/interfaces/mock_interfaces"
|
||||
"elyby/minecraft-skinsystem/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")
|
||||
}
|
||||
|
||||
func TestBuildElyUrl(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
assert.Equal("http://ely.by/route", buildElyUrl("/route"), "Function should add prefix to the provided relative url.")
|
||||
assert.Equal("http://ely.by/test/route", buildElyUrl("http://ely.by/test/route"), "Function should do not add prefix to the provided prefixed url.")
|
||||
}
|
||||
|
||||
func setupMocks(ctrl *gomock.Controller) (
|
||||
*Config,
|
||||
*mock_interfaces.MockSkinsRepository,
|
||||
*mock_interfaces.MockCapesRepository,
|
||||
*mock_wd.MockWatchdog,
|
||||
) {
|
||||
skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl)
|
||||
capesRepo := mock_interfaces.NewMockCapesRepository(ctrl)
|
||||
wd := mock_wd.NewMockWatchdog(ctrl)
|
||||
|
||||
return &Config{
|
||||
SkinsRepo: skinsRepo,
|
||||
CapesRepo: capesRepo,
|
||||
Logger: wd,
|
||||
}, skinsRepo, capesRepo, wd
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (cfg *Config) NotFound(response http.ResponseWriter, request *http.Request) {
|
||||
data, _ := json.Marshal(map[string]string{
|
||||
"status": "404",
|
||||
"message": "Not Found",
|
||||
"link": "http://docs.ely.by/skin-system.html",
|
||||
})
|
||||
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.WriteHeader(http.StatusNotFound)
|
||||
response.Write(data)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfig_NotFound(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
(&Config{}).CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(404, resp.StatusCode)
|
||||
assert.Equal("application/json", resp.Header.Get("Content-Type"))
|
||||
response, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.JSONEq(`{
|
||||
"status": "404",
|
||||
"message": "Not Found",
|
||||
"link": "http://docs.ely.by/skin-system.html"
|
||||
}`, string(response))
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type signedTexturesResponse struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsEly bool `json:"ely,omitempty"`
|
||||
Props []property `json:"properties"`
|
||||
}
|
||||
|
||||
type property struct {
|
||||
Name string `json:"name"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (cfg *Config) SignedTextures(response http.ResponseWriter, request *http.Request) {
|
||||
cfg.Logger.IncCounter("signed_textures.request", 1)
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
|
||||
rec, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
if err != nil || rec.SkinId == 0 || rec.MojangTextures == "" {
|
||||
response.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
responseData:= signedTexturesResponse{
|
||||
Id: strings.Replace(rec.Uuid, "-", "", -1),
|
||||
Name: rec.Username,
|
||||
Props: []property{
|
||||
{
|
||||
Name: "textures",
|
||||
Signature: rec.MojangSignature,
|
||||
Value: rec.MojangTextures,
|
||||
},
|
||||
{
|
||||
Name: "ely",
|
||||
Value: "but why are you asking?",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
responseJson,_ := json.Marshal(responseData)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Write(responseJson)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
)
|
||||
|
||||
func TestConfig_SignedTextures(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
|
||||
wd.EXPECT().IncCounter("signed_textures.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(200, resp.StatusCode)
|
||||
assert.Equal("application/json", resp.Header.Get("Content-Type"))
|
||||
response, _ := ioutil.ReadAll(resp.Body)
|
||||
assert.JSONEq(`{
|
||||
"id": "0f657aa8bfbe415db7005750090d3af3",
|
||||
"name": "mock_user",
|
||||
"properties": [
|
||||
{
|
||||
"name": "textures",
|
||||
"signature": "mocked signature",
|
||||
"value": "mocked textures base64"
|
||||
},
|
||||
{
|
||||
"name": "ely",
|
||||
"value": "but why are you asking?"
|
||||
}
|
||||
]
|
||||
}`, string(response))
|
||||
}
|
||||
|
||||
func TestConfig_SignedTextures2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{})
|
||||
wd.EXPECT().IncCounter("signed_textures.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/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))
|
||||
}
|
||||
36
http/skin.go
36
http/skin.go
@@ -1,36 +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, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(response, request, buildElyUrl(rec.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,124 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
"elyby/minecraft-skinsystem/model"
|
||||
)
|
||||
|
||||
func TestConfig_Skin(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
|
||||
wd.EXPECT().IncCounter("skins.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_Skin2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"})
|
||||
wd.EXPECT().IncCounter("skins.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/notch", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_SkinGET(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
|
||||
wd.EXPECT().IncCounter("skins.get_request", int64(1))
|
||||
wd.EXPECT().IncCounter("skins.request", int64(1)).Times(0)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=mock_user", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_SkinGET2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, _, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"})
|
||||
wd.EXPECT().IncCounter("skins.get_request", int64(1))
|
||||
wd.EXPECT().IncCounter("skins.request", int64(1)).Times(0)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=notch", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
config.CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func TestConfig_SkinGET3(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/?name=notch", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
(&Config{}).CreateHandler().ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
assert.Equal(301, resp.StatusCode)
|
||||
assert.Equal("http://skinsystem.ely.by/skins?name=notch", resp.Header.Get("Location"))
|
||||
}
|
||||
|
||||
func createSkinModel(username string, isSlim bool) *model.Skin {
|
||||
return &model.Skin{
|
||||
Username: username,
|
||||
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
|
||||
SkinId: 1,
|
||||
Hash: "55d2a8848764f5ff04012cdb093458bd",
|
||||
Url: "http://ely.by/minecraft/skins/skin.png",
|
||||
MojangTextures: "mocked textures base64",
|
||||
MojangSignature: "mocked signature",
|
||||
IsSlim: isSlim,
|
||||
}
|
||||
}
|
||||
104
http/textures.go
104
http/textures.go
@@ -1,104 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"elyby/minecraft-skinsystem/model"
|
||||
)
|
||||
|
||||
type texturesResponse struct {
|
||||
Skin *Skin `json:"SKIN"`
|
||||
Cape *Cape `json:"CAPE,omitempty"`
|
||||
}
|
||||
|
||||
type Skin struct {
|
||||
Url string `json:"url"`
|
||||
Hash string `json:"hash"`
|
||||
Metadata *skinMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type skinMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
type Cape struct {
|
||||
Url string `json:"url"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Textures(response http.ResponseWriter, request *http.Request) {
|
||||
cfg.Logger.IncCounter("textures.request", 1)
|
||||
username := parseUsername(mux.Vars(request)["username"])
|
||||
|
||||
skin, err := cfg.SkinsRepo.FindByUsername(username)
|
||||
if err != nil || skin.SkinId == 0 {
|
||||
if skin == nil {
|
||||
skin = &model.Skin{}
|
||||
}
|
||||
|
||||
skin.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png"
|
||||
skin.Hash = string(buildNonElyTexturesHash(username))
|
||||
} else {
|
||||
skin.Url = buildElyUrl(skin.Url)
|
||||
}
|
||||
|
||||
textures := texturesResponse{
|
||||
Skin: &Skin{
|
||||
Url: skin.Url,
|
||||
Hash: skin.Hash,
|
||||
},
|
||||
}
|
||||
|
||||
if skin.IsSlim {
|
||||
textures.Skin.Metadata = &skinMetadata{
|
||||
Model: "slim",
|
||||
}
|
||||
}
|
||||
|
||||
cape, err := cfg.CapesRepo.FindByUsername(username)
|
||||
if err == nil {
|
||||
var scheme string = "http://"
|
||||
if request.TLS != nil {
|
||||
scheme = "https://"
|
||||
}
|
||||
|
||||
textures.Cape = &Cape{
|
||||
Url: scheme + request.Host + "/cloaks/" + username,
|
||||
Hash: calculateCapeHash(cape),
|
||||
}
|
||||
}
|
||||
|
||||
responseData, _ := json.Marshal(textures)
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.Write(responseData)
|
||||
}
|
||||
|
||||
func calculateCapeHash(cape *model.Cape) string {
|
||||
hasher := md5.New()
|
||||
io.Copy(hasher, cape.File)
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
func buildNonElyTexturesHash(username string) string {
|
||||
hour := getCurrentHour()
|
||||
hasher := md5.New()
|
||||
hasher.Write([]byte("non-ely-" + strconv.FormatInt(hour, 10) + "-" + username))
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
var timeNow = time.Now
|
||||
|
||||
func getCurrentHour() int64 {
|
||||
n := timeNow()
|
||||
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), 0, 0, 0, time.UTC).Unix()
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
testify "github.com/stretchr/testify/assert"
|
||||
|
||||
"elyby/minecraft-skinsystem/db"
|
||||
"elyby/minecraft-skinsystem/model"
|
||||
)
|
||||
|
||||
func TestConfig_Textures(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
|
||||
capesRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"})
|
||||
wd.EXPECT().IncCounter("textures.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/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://ely.by/minecraft/skins/skin.png",
|
||||
"hash": "55d2a8848764f5ff04012cdb093458bd"
|
||||
}
|
||||
}`, string(response))
|
||||
}
|
||||
|
||||
func TestConfig_Textures2(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil)
|
||||
capesRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"})
|
||||
wd.EXPECT().IncCounter("textures.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/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://ely.by/minecraft/skins/skin.png",
|
||||
"hash": "55d2a8848764f5ff04012cdb093458bd",
|
||||
"metadata": {
|
||||
"model": "slim"
|
||||
}
|
||||
}
|
||||
}`, string(response))
|
||||
}
|
||||
|
||||
func TestConfig_Textures3(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
|
||||
capesRepo.EXPECT().FindByUsername("mock_user").Return(&model.Cape{
|
||||
File: bytes.NewReader(createCape()),
|
||||
}, nil)
|
||||
wd.EXPECT().IncCounter("textures.request", int64(1))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/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://ely.by/minecraft/skins/skin.png",
|
||||
"hash": "55d2a8848764f5ff04012cdb093458bd"
|
||||
},
|
||||
"CAPE": {
|
||||
"url": "http://skinsystem.ely.by/cloaks/mock_user",
|
||||
"hash": "424ff79dce9940af89c28ad80de8aaad"
|
||||
}
|
||||
}`, string(response))
|
||||
}
|
||||
|
||||
func TestConfig_Textures4(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
config, skinsRepo, capesRepo, wd := setupMocks(ctrl)
|
||||
|
||||
skinsRepo.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{})
|
||||
capesRepo.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{})
|
||||
wd.EXPECT().IncCounter("textures.request", int64(1))
|
||||
timeNow = func() time.Time {
|
||||
return time.Date(2017, time.August, 20, 0, 15, 54, 0, time.UTC)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/notch", 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://skins.minecraft.net/MinecraftSkins/notch.png",
|
||||
"hash": "5923cf3f7fa170a279e4d7a9483cfc52"
|
||||
}
|
||||
}`, string(response))
|
||||
}
|
||||
|
||||
func TestBuildNonElyTexturesHash(t *testing.T) {
|
||||
assert := testify.New(t)
|
||||
timeNow = func() time.Time {
|
||||
return time.Date(2017, time.November, 30, 16, 15, 34, 0, time.UTC)
|
||||
}
|
||||
|
||||
assert.Equal("686d788a5353cb636e8fdff727634d88", buildNonElyTexturesHash("username"), "Function should return fixed hash by username-time pair")
|
||||
assert.Equal("fb876f761683a10accdb17d403cef64c", buildNonElyTexturesHash("another-username"), "Function should return fixed hash by username-time pair")
|
||||
|
||||
timeNow = func() time.Time {
|
||||
return time.Date(2017, time.November, 30, 16, 20, 12, 0, time.UTC)
|
||||
}
|
||||
|
||||
assert.Equal("686d788a5353cb636e8fdff727634d88", buildNonElyTexturesHash("username"), "Function should do not change it's value if hour the same")
|
||||
assert.Equal("fb876f761683a10accdb17d403cef64c", buildNonElyTexturesHash("another-username"), "Function should return fixed hash by username-time pair")
|
||||
|
||||
timeNow = func() time.Time {
|
||||
return time.Date(2017, time.November, 30, 17, 1, 3, 0, time.UTC)
|
||||
}
|
||||
|
||||
assert.Equal("42277892fd24bc0ed86285b3bb8b8fad", buildNonElyTexturesHash("username"), "Function should change it's value if hour changed")
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"elyby/minecraft-skinsystem/api/accounts"
|
||||
)
|
||||
|
||||
type AccountsAPI interface {
|
||||
AccountInfo(attribute string, value string) (*accounts.AccountInfoResponse, error)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: interfaces/api.go
|
||||
|
||||
package mock_interfaces
|
||||
|
||||
import (
|
||||
accounts "elyby/minecraft-skinsystem/api/accounts"
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockAccountsAPI is a mock of AccountsAPI interface
|
||||
type MockAccountsAPI struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockAccountsAPIMockRecorder
|
||||
}
|
||||
|
||||
// MockAccountsAPIMockRecorder is the mock recorder for MockAccountsAPI
|
||||
type MockAccountsAPIMockRecorder struct {
|
||||
mock *MockAccountsAPI
|
||||
}
|
||||
|
||||
// NewMockAccountsAPI creates a new mock instance
|
||||
func NewMockAccountsAPI(ctrl *gomock.Controller) *MockAccountsAPI {
|
||||
mock := &MockAccountsAPI{ctrl: ctrl}
|
||||
mock.recorder = &MockAccountsAPIMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (_m *MockAccountsAPI) EXPECT() *MockAccountsAPIMockRecorder {
|
||||
return _m.recorder
|
||||
}
|
||||
|
||||
// AccountInfo mocks base method
|
||||
func (_m *MockAccountsAPI) AccountInfo(attribute string, value string) (*accounts.AccountInfoResponse, error) {
|
||||
ret := _m.ctrl.Call(_m, "AccountInfo", attribute, value)
|
||||
ret0, _ := ret[0].(*accounts.AccountInfoResponse)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// AccountInfo indicates an expected call of AccountInfo
|
||||
func (_mr *MockAccountsAPIMockRecorder) AccountInfo(arg0, arg1 interface{}) *gomock.Call {
|
||||
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "AccountInfo", reflect.TypeOf((*MockAccountsAPI)(nil).AccountInfo), arg0, arg1)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: interfaces/repositories.go
|
||||
|
||||
package mock_interfaces
|
||||
|
||||
import (
|
||||
model "elyby/minecraft-skinsystem/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)
|
||||
}
|
||||
|
||||
// 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,15 +0,0 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"elyby/minecraft-skinsystem/model"
|
||||
)
|
||||
|
||||
type SkinsRepository interface {
|
||||
FindByUsername(username string) (*model.Skin, error)
|
||||
FindByUserId(id int) (*model.Skin, error)
|
||||
Save(skin *model.Skin) error
|
||||
}
|
||||
|
||||
type CapesRepository interface {
|
||||
FindByUsername(username string) (*model.Cape, error)
|
||||
}
|
||||
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)
|
||||
}
|
||||
32
internal/cmd/version.go
Normal file
32
internal/cmd/version.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"ely.by/chrly/internal/version"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Show the Chrly version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
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
|
||||
}
|
||||
131
internal/mojang/uuids_provider_test.go
Normal file
131
internal/mojang/uuids_provider_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package mojang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
var mockProfile = &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "UserName"}
|
||||
|
||||
type UuidsProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *UuidsProviderMock) 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 MojangUuidsStorageMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangUuidsStorageMock) GetUuidForMojangUsername(ctx context.Context, username string) (string, string, error) {
|
||||
args := m.Called(ctx, username)
|
||||
return args.String(0), args.String(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MojangUuidsStorageMock) StoreMojangUuid(ctx context.Context, username string, uuid string) error {
|
||||
m.Called(ctx, username, uuid)
|
||||
return nil
|
||||
}
|
||||
|
||||
type UuidsProviderWithCacheSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Original *UuidsProviderMock
|
||||
Storage *MojangUuidsStorageMock
|
||||
Provider *UuidsProviderWithCache
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) SetupTest() {
|
||||
s.Original = &UuidsProviderMock{}
|
||||
s.Storage = &MojangUuidsStorageMock{}
|
||||
s.Provider, _ = NewUuidsProviderWithCache(s.Original, s.Storage)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TearDownTest() {
|
||||
s.Original.AssertExpectations(s.T())
|
||||
s.Storage.AssertExpectations(s.T())
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUncachedSuccessfully() {
|
||||
ctx := context.Background()
|
||||
|
||||
s.Storage.On("GetUuidForMojangUsername", ctx, "username").Return("", "", nil)
|
||||
s.Storage.On("StoreMojangUuid", ctx, "UserName", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
|
||||
|
||||
s.Original.On("GetUuid", ctx, "username").Once().Return(mockProfile, nil)
|
||||
|
||||
result, err := s.Provider.GetUuid(ctx, "username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Equal(mockProfile, result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUncachedNotExistsMojangUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "", nil)
|
||||
s.Storage.On("StoreMojangUuid", mock.Anything, "username", "").Once().Return(nil)
|
||||
|
||||
s.Original.On("GetUuid", mock.Anything, "username").Once().Return(nil, nil)
|
||||
|
||||
result, err := s.Provider.GetUuid(context.Background(), "username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestKnownCachedUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("mock-uuid", "UserName", nil)
|
||||
|
||||
result, err := s.Provider.GetUuid(context.Background(), "username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().NotNil(result)
|
||||
s.Require().Equal("UserName", result.Name)
|
||||
s.Require().Equal("mock-uuid", result.Id)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestUnknownCachedUsername() {
|
||||
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "UserName", nil)
|
||||
|
||||
result, err := s.Provider.GetUuid(context.Background(), "username")
|
||||
|
||||
s.Require().NoError(err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestErrorDuringCacheQuery() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "", expectedError)
|
||||
|
||||
result, err := s.Provider.GetUuid(context.Background(), "username")
|
||||
|
||||
s.Require().Same(expectedError, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func (s *UuidsProviderWithCacheSuite) TestErrorFromOriginalProvider() {
|
||||
expectedError := errors.New("mock error")
|
||||
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "", nil)
|
||||
|
||||
s.Original.On("GetUuid", mock.Anything, "username").Once().Return(nil, expectedError)
|
||||
|
||||
result, err := s.Provider.GetUuid(context.Background(), "username")
|
||||
|
||||
s.Require().Same(expectedError, err)
|
||||
s.Require().Nil(result)
|
||||
}
|
||||
|
||||
func TestUuidsProviderWithCache(t *testing.T) {
|
||||
suite.Run(t, new(UuidsProviderWithCacheSuite))
|
||||
}
|
||||
17
internal/otel/otel.go
Normal file
17
internal/otel/otel.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package otel
|
||||
|
||||
import (
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const Scope = "ely.by/chrly"
|
||||
|
||||
func GetMeter(opts ...metric.MeterOption) metric.Meter {
|
||||
return otel.GetMeterProvider().Meter(Scope, opts...)
|
||||
}
|
||||
|
||||
func GetTracer(opts ...trace.TracerOption) trace.Tracer {
|
||||
return otel.GetTracerProvider().Tracer(Scope, opts...)
|
||||
}
|
||||
148
internal/otel/setup.go
Normal file
148
internal/otel/setup.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package otel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/agoda-com/opentelemetry-go/otelslog"
|
||||
logsOtel "github.com/agoda-com/opentelemetry-logs-go"
|
||||
logsAutoconfig "github.com/agoda-com/opentelemetry-logs-go/autoconfigure/sdk/logs"
|
||||
"github.com/agoda-com/opentelemetry-logs-go/sdk/logs"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
runtimeMetrics "go.opentelemetry.io/contrib/instrumentation/runtime"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/semconv/v1.4.0"
|
||||
|
||||
"ely.by/chrly/internal/version"
|
||||
)
|
||||
|
||||
func SetupOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
|
||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||
// The errors from the calls are joined.
|
||||
// Each registered cleanup will be invoked once
|
||||
shutdown = func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
err = errors.Join(err, fn(ctx))
|
||||
}
|
||||
|
||||
shutdownFuncs = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
// Set up propagator
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// Set up resource
|
||||
res, err := newResource(ctx)
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up logs provider
|
||||
logsProvider, err := newLoggerProvider(ctx, res)
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
shutdownFuncs = append(shutdownFuncs, logsProvider.Shutdown)
|
||||
logsOtel.SetLoggerProvider(logsProvider)
|
||||
|
||||
otelSlog := slog.New(otelslog.NewOtelHandler(logsProvider, &otelslog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
slog.SetDefault(otelSlog)
|
||||
|
||||
// Set up trace provider
|
||||
tracerProvider, err := newTraceProvider(ctx, res)
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// Set up meter provider
|
||||
meterProvider, err := newMeterProvider(ctx, res)
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
err = runtimeMetrics.Start(runtimeMetrics.WithMinimumReadMemStatsInterval(time.Second))
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newPropagator() propagation.TextMapPropagator {
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
}
|
||||
|
||||
func newResource(ctx context.Context) (*resource.Resource, error) {
|
||||
return resource.New(
|
||||
ctx,
|
||||
resource.WithFromEnv(),
|
||||
resource.WithTelemetrySDK(),
|
||||
resource.WithOS(),
|
||||
resource.WithContainer(),
|
||||
resource.WithHost(),
|
||||
resource.WithAttributes(
|
||||
semconv.ServiceNameKey.String("chrly"),
|
||||
semconv.ServiceVersionKey.String(version.Version()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func newLoggerProvider(ctx context.Context, res *resource.Resource) (*logs.LoggerProvider, error) {
|
||||
return logsAutoconfig.NewLoggerProvider(ctx, logsAutoconfig.WithResource(res)), nil
|
||||
}
|
||||
|
||||
func newTraceProvider(ctx context.Context, res *resource.Resource) (*trace.TracerProvider, error) {
|
||||
exporter, err := autoexport.NewSpanExporter(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return trace.NewTracerProvider(
|
||||
trace.WithBatcher(exporter),
|
||||
trace.WithResource(res),
|
||||
), nil
|
||||
}
|
||||
|
||||
func newMeterProvider(ctx context.Context, res *resource.Resource) (*metric.MeterProvider, error) {
|
||||
reader, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return metric.NewMeterProvider(
|
||||
metric.WithReader(reader),
|
||||
metric.WithResource(res),
|
||||
), nil
|
||||
}
|
||||
122
internal/profiles/manager.go
Normal file
122
internal/profiles/manager.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
)
|
||||
|
||||
type ProfilesRepository interface {
|
||||
SaveProfile(ctx context.Context, profile *db.Profile) error
|
||||
RemoveProfileByUuid(ctx context.Context, uuid string) error
|
||||
}
|
||||
|
||||
func NewManager(pr ProfilesRepository) *Manager {
|
||||
return &Manager{
|
||||
ProfilesRepository: pr,
|
||||
profileValidator: createProfileValidator(),
|
||||
}
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
ProfilesRepository
|
||||
profileValidator *validator.Validate
|
||||
}
|
||||
|
||||
func (m *Manager) PersistProfile(ctx context.Context, profile *db.Profile) error {
|
||||
validationErrors := m.profileValidator.Struct(profile)
|
||||
if validationErrors != nil {
|
||||
return mapValidationErrorsToCommonError(validationErrors.(validator.ValidationErrors))
|
||||
}
|
||||
|
||||
profile.Uuid = cleanupUuid(profile.Uuid)
|
||||
if profile.SkinUrl == "" || isClassicModel(profile.SkinModel) {
|
||||
profile.SkinModel = ""
|
||||
}
|
||||
|
||||
return m.ProfilesRepository.SaveProfile(ctx, profile)
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveProfileByUuid(ctx context.Context, uuid string) error {
|
||||
return m.ProfilesRepository.RemoveProfileByUuid(ctx, cleanupUuid(uuid))
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
Errors map[string][]string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return "The profile is invalid and cannot be persisted"
|
||||
}
|
||||
|
||||
func cleanupUuid(uuid string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(uuid), "-", "")
|
||||
}
|
||||
|
||||
func createProfileValidator() *validator.Validate {
|
||||
validate := validator.New()
|
||||
|
||||
regexUuidAny := regexp.MustCompile("(?i)^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$")
|
||||
_ = validate.RegisterValidation("uuid_any", func(fl validator.FieldLevel) bool {
|
||||
return regexUuidAny.MatchString(fl.Field().String())
|
||||
})
|
||||
|
||||
regexUsername := regexp.MustCompile(`^[-\w.!$%^&*()\[\]:;]+$`)
|
||||
_ = validate.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||
return regexUsername.MatchString(fl.Field().String())
|
||||
})
|
||||
|
||||
validate.RegisterStructValidationMapRules(map[string]string{
|
||||
"Username": "required,username,max=21",
|
||||
"Uuid": "required,uuid_any",
|
||||
"SkinUrl": "omitempty,url",
|
||||
"SkinModel": "omitempty,max=20",
|
||||
"CapeUrl": "omitempty,url",
|
||||
"MojangTextures": "omitempty,base64",
|
||||
"MojangSignature": "required_with=MojangTextures,omitempty,base64",
|
||||
}, db.Profile{})
|
||||
|
||||
return validate
|
||||
}
|
||||
|
||||
func mapValidationErrorsToCommonError(err validator.ValidationErrors) *ValidationError {
|
||||
resultErr := &ValidationError{make(map[string][]string)}
|
||||
for _, e := range err {
|
||||
// Manager can return multiple errors per field, but the current validation implementation
|
||||
// returns only one error per field
|
||||
resultErr.Errors[e.Field()] = []string{formatValidationErr(e)}
|
||||
}
|
||||
|
||||
return resultErr
|
||||
}
|
||||
|
||||
// The go-playground/validator lib already contains tools for translated errors output.
|
||||
// However, the implementation is very heavy and becomes even more so when you need to add messages for custom validators.
|
||||
// So for simplicity, I've extracted validation error formatting into this simple implementation
|
||||
func formatValidationErr(err validator.FieldError) string {
|
||||
switch err.Tag() {
|
||||
case "required", "required_with":
|
||||
return fmt.Sprintf("%s is a required field", err.Field())
|
||||
case "username":
|
||||
return fmt.Sprintf("%s must be a valid username", err.Field())
|
||||
case "max":
|
||||
return fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param())
|
||||
case "uuid_any":
|
||||
return fmt.Sprintf("%s must be a valid UUID", err.Field())
|
||||
case "url":
|
||||
return fmt.Sprintf("%s must be a valid URL", err.Field())
|
||||
case "base64":
|
||||
return fmt.Sprintf("%s must be a valid Base64 string", err.Field())
|
||||
default:
|
||||
return fmt.Sprintf(`Field validation for "%s" failed on the "%s" tag`, err.Field(), err.Tag())
|
||||
}
|
||||
}
|
||||
|
||||
func isClassicModel(model string) bool {
|
||||
return model == "" || model == "classic" || model == "default" || model == "steve"
|
||||
}
|
||||
130
internal/profiles/manager_test.go
Normal file
130
internal/profiles/manager_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
)
|
||||
|
||||
type ProfilesRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *ProfilesRepositoryMock) SaveProfile(ctx context.Context, profile *db.Profile) error {
|
||||
return m.Called(ctx, profile).Error(0)
|
||||
}
|
||||
|
||||
func (m *ProfilesRepositoryMock) RemoveProfileByUuid(ctx context.Context, uuid string) error {
|
||||
return m.Called(ctx, uuid).Error(0)
|
||||
}
|
||||
|
||||
type ManagerTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Manager *Manager
|
||||
|
||||
ProfilesRepository *ProfilesRepositoryMock
|
||||
}
|
||||
|
||||
func (t *ManagerTestSuite) SetupSubTest() {
|
||||
t.ProfilesRepository = &ProfilesRepositoryMock{}
|
||||
t.Manager = NewManager(t.ProfilesRepository)
|
||||
}
|
||||
|
||||
func (t *ManagerTestSuite) TearDownSubTest() {
|
||||
t.ProfilesRepository.AssertExpectations(t.T())
|
||||
}
|
||||
|
||||
func (t *ManagerTestSuite) TestPersistProfile() {
|
||||
t.Run("valid profile (full)", func() {
|
||||
ctx := context.Background()
|
||||
profile := &db.Profile{
|
||||
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
|
||||
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=",
|
||||
}
|
||||
t.ProfilesRepository.On("SaveProfile", ctx, profile).Once().Return(nil)
|
||||
|
||||
err := t.Manager.PersistProfile(ctx, profile)
|
||||
t.NoError(err)
|
||||
})
|
||||
|
||||
t.Run("valid profile (minimal)", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
|
||||
Username: "mock-username",
|
||||
}
|
||||
t.ProfilesRepository.On("SaveProfile", mock.Anything, profile).Once().Return(nil)
|
||||
|
||||
err := t.Manager.PersistProfile(context.Background(), profile)
|
||||
t.NoError(err)
|
||||
})
|
||||
|
||||
t.Run("normalize uuid and skin model", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "BA866A9C-C839-4268-A30F-7B26AE604C51",
|
||||
Username: "mock-username",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
SkinModel: "default",
|
||||
}
|
||||
expectedProfile := *profile
|
||||
expectedProfile.Uuid = "ba866a9cc8394268a30f7b26ae604c51"
|
||||
expectedProfile.SkinModel = ""
|
||||
t.ProfilesRepository.On("SaveProfile", mock.Anything, &expectedProfile).Once().Return(nil)
|
||||
|
||||
err := t.Manager.PersistProfile(context.Background(), profile)
|
||||
t.NoError(err)
|
||||
})
|
||||
|
||||
t.Run("require mojangSignature when mojangTexturesProvided", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
|
||||
Username: "mock-username",
|
||||
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
|
||||
}
|
||||
|
||||
err := t.Manager.PersistProfile(context.Background(), profile)
|
||||
t.Error(err)
|
||||
t.IsType(&ValidationError{}, err)
|
||||
castedErr := err.(*ValidationError)
|
||||
mojangSignatureErr, mojangSignatureErrExists := castedErr.Errors["MojangSignature"]
|
||||
t.True(mojangSignatureErrExists)
|
||||
t.Contains(mojangSignatureErr[0], "required")
|
||||
})
|
||||
|
||||
t.Run("validate username", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
|
||||
Username: "invalid\"username",
|
||||
}
|
||||
|
||||
err := t.Manager.PersistProfile(context.Background(), profile)
|
||||
t.Error(err)
|
||||
t.IsType(&ValidationError{}, err)
|
||||
castedErr := err.(*ValidationError)
|
||||
usernameErrs, usernameErrExists := castedErr.Errors["Username"]
|
||||
t.True(usernameErrExists)
|
||||
t.Contains(usernameErrs[0], "valid")
|
||||
})
|
||||
|
||||
t.Run("empty profile", func() {
|
||||
profile := &db.Profile{}
|
||||
|
||||
err := t.Manager.PersistProfile(context.Background(), profile)
|
||||
t.Error(err)
|
||||
t.IsType(&ValidationError{}, err)
|
||||
// TODO: validate errors
|
||||
})
|
||||
}
|
||||
|
||||
func TestManager(t *testing.T) {
|
||||
suite.Run(t, new(ManagerTestSuite))
|
||||
}
|
||||
96
internal/profiles/provider.go
Normal file
96
internal/profiles/provider.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
"ely.by/chrly/internal/mojang"
|
||||
)
|
||||
|
||||
type ProfilesFinder interface {
|
||||
FindProfileByUsername(ctx context.Context, username string) (*db.Profile, error)
|
||||
}
|
||||
|
||||
type MojangProfilesProvider interface {
|
||||
GetForUsername(ctx context.Context, username string) (*mojang.ProfileResponse, error)
|
||||
}
|
||||
|
||||
func NewProvider(pf ProfilesFinder, mpf MojangProfilesProvider) (*Provider, error) {
|
||||
return &Provider{
|
||||
ProfilesFinder: pf,
|
||||
MojangProfilesProvider: mpf,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
ProfilesFinder
|
||||
MojangProfilesProvider
|
||||
}
|
||||
|
||||
func (p *Provider) FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error) {
|
||||
profile, err := p.ProfilesFinder.FindProfileByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if profile != nil && (profile.SkinUrl != "" || profile.CapeUrl != "") {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
if allowProxy {
|
||||
mojangProfile, err := p.MojangProfilesProvider.GetForUsername(ctx, username)
|
||||
// If we at least know something about the user,
|
||||
// then we can ignore an error and return profile without textures
|
||||
if err != nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
if err != nil || mojangProfile == nil {
|
||||
if errors.Is(err, mojang.InvalidUsername) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decodedTextures, err := mojangProfile.DecodeTextures()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile = &db.Profile{
|
||||
Uuid: mojangProfile.Id,
|
||||
Username: mojangProfile.Name,
|
||||
}
|
||||
|
||||
// There might be no textures property
|
||||
if decodedTextures != nil {
|
||||
if decodedTextures.Textures.Skin != nil {
|
||||
profile.SkinUrl = decodedTextures.Textures.Skin.Url
|
||||
if decodedTextures.Textures.Skin.Metadata != nil {
|
||||
profile.SkinModel = decodedTextures.Textures.Skin.Metadata.Model
|
||||
}
|
||||
}
|
||||
|
||||
if decodedTextures.Textures.Cape != nil {
|
||||
profile.CapeUrl = decodedTextures.Textures.Cape.Url
|
||||
}
|
||||
}
|
||||
|
||||
var texturesProp *mojang.Property
|
||||
for _, prop := range mojangProfile.Props {
|
||||
if prop.Name == "textures" {
|
||||
texturesProp = prop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if texturesProp != nil {
|
||||
profile.MojangTextures = texturesProp.Value
|
||||
profile.MojangSignature = texturesProp.Signature
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
275
internal/profiles/provider_test.go
Normal file
275
internal/profiles/provider_test.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
|
||||
"ely.by/chrly/internal/db"
|
||||
"ely.by/chrly/internal/mojang"
|
||||
"ely.by/chrly/internal/utils"
|
||||
)
|
||||
|
||||
type ProfilesFinderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *ProfilesFinderMock) FindProfileByUsername(ctx context.Context, username string) (*db.Profile, error) {
|
||||
args := m.Called(ctx, username)
|
||||
var result *db.Profile
|
||||
if casted, ok := args.Get(0).(*db.Profile); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type MojangProfilesProviderMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MojangProfilesProviderMock) GetForUsername(ctx context.Context, username string) (*mojang.ProfileResponse, error) {
|
||||
args := m.Called(ctx, username)
|
||||
var result *mojang.ProfileResponse
|
||||
if casted, ok := args.Get(0).(*mojang.ProfileResponse); ok {
|
||||
result = casted
|
||||
}
|
||||
|
||||
return result, args.Error(1)
|
||||
}
|
||||
|
||||
type CombinedProfilesProviderSuite struct {
|
||||
suite.Suite
|
||||
|
||||
Provider *Provider
|
||||
|
||||
ProfilesFinder *ProfilesFinderMock
|
||||
MojangProfilesProvider *MojangProfilesProviderMock
|
||||
}
|
||||
|
||||
func (t *CombinedProfilesProviderSuite) SetupSubTest() {
|
||||
t.ProfilesFinder = &ProfilesFinderMock{}
|
||||
t.MojangProfilesProvider = &MojangProfilesProviderMock{}
|
||||
t.Provider, _ = NewProvider(
|
||||
t.ProfilesFinder,
|
||||
t.MojangProfilesProvider,
|
||||
)
|
||||
}
|
||||
|
||||
func (t *CombinedProfilesProviderSuite) TearDownSubTest() {
|
||||
t.ProfilesFinder.AssertExpectations(t.T())
|
||||
t.MojangProfilesProvider.AssertExpectations(t.T())
|
||||
}
|
||||
|
||||
func (t *CombinedProfilesProviderSuite) TestFindByUsername() {
|
||||
t.Run("exists profile with a skin", func() {
|
||||
ctx := context.Background()
|
||||
profile := &db.Profile{
|
||||
Uuid: "mock-uuid",
|
||||
Username: "Mock",
|
||||
SkinUrl: "https://example.com/skin.png",
|
||||
}
|
||||
t.ProfilesFinder.On("FindProfileByUsername", ctx, "Mock").Return(profile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(ctx, "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Same(profile, foundProfile)
|
||||
})
|
||||
|
||||
t.Run("exists profile with a cape", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "mock-uuid",
|
||||
Username: "Mock",
|
||||
CapeUrl: "https://example.com/cape.png",
|
||||
}
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(profile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Same(profile, foundProfile)
|
||||
})
|
||||
|
||||
t.Run("exists profile without textures (no proxy)", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "mock-uuid",
|
||||
Username: "Mock",
|
||||
}
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(profile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", false)
|
||||
t.NoError(err)
|
||||
t.Same(profile, foundProfile)
|
||||
})
|
||||
|
||||
t.Run("not exists profile (no proxy)", func() {
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", false)
|
||||
t.NoError(err)
|
||||
t.Nil(foundProfile)
|
||||
})
|
||||
|
||||
t.Run("handle error from profiles repository", func() {
|
||||
expectedError := errors.New("mock error")
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, expectedError)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", false)
|
||||
t.Same(expectedError, err)
|
||||
t.Nil(foundProfile)
|
||||
})
|
||||
|
||||
t.Run("exists profile without textures (with proxy)", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "mock-uuid",
|
||||
Username: "Mock",
|
||||
}
|
||||
mojangProfile := createMojangProfile(true, true)
|
||||
ctx := context.Background()
|
||||
t.ProfilesFinder.On("FindProfileByUsername", ctx, "Mock").Return(profile, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", ctx, "Mock").Return(mojangProfile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(ctx, "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Equal(&db.Profile{
|
||||
Uuid: "mock-mojang-uuid",
|
||||
Username: "mOcK",
|
||||
SkinUrl: "https://mojang/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://mojang/cape.png",
|
||||
MojangTextures: mojangProfile.Props[0].Value,
|
||||
MojangSignature: mojangProfile.Props[0].Signature,
|
||||
}, foundProfile)
|
||||
})
|
||||
|
||||
t.Run("not exists profile (with proxy)", func() {
|
||||
mojangProfile := createMojangProfile(true, true)
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(mojangProfile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Equal(&db.Profile{
|
||||
Uuid: "mock-mojang-uuid",
|
||||
Username: "mOcK",
|
||||
SkinUrl: "https://mojang/skin.png",
|
||||
SkinModel: "slim",
|
||||
CapeUrl: "https://mojang/cape.png",
|
||||
MojangTextures: mojangProfile.Props[0].Value,
|
||||
MojangSignature: mojangProfile.Props[0].Signature,
|
||||
}, foundProfile)
|
||||
})
|
||||
|
||||
t.Run("should return known profile without textures when received an error from the mojang", func() {
|
||||
profile := &db.Profile{
|
||||
Uuid: "mock-uuid",
|
||||
Username: "Mock",
|
||||
}
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(profile, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(nil, errors.New("mock error"))
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Same(profile, foundProfile)
|
||||
})
|
||||
|
||||
t.Run("should not return an error when passed the invalid username", func() {
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(nil, mojang.InvalidUsername)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Nil(foundProfile)
|
||||
})
|
||||
|
||||
t.Run("should return an error from mojang provider", func() {
|
||||
expectedError := errors.New("mock error")
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(nil, expectedError)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.Same(expectedError, err)
|
||||
t.Nil(foundProfile)
|
||||
})
|
||||
|
||||
t.Run("should correctly handle invalid textures from mojang", func() {
|
||||
mojangProfile := &mojang.ProfileResponse{
|
||||
Props: []*mojang.Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: "this is invalid base64",
|
||||
Signature: "mojang signature",
|
||||
},
|
||||
},
|
||||
}
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(mojangProfile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.ErrorContains(err, "illegal base64 data")
|
||||
t.Nil(foundProfile)
|
||||
})
|
||||
|
||||
t.Run("should correctly handle missing textures property from Mojang", func() {
|
||||
mojangProfile := &mojang.ProfileResponse{
|
||||
Id: "mock-mojang-uuid",
|
||||
Name: "mOcK",
|
||||
Props: []*mojang.Property{},
|
||||
}
|
||||
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
|
||||
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(mojangProfile, nil)
|
||||
|
||||
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
|
||||
t.NoError(err)
|
||||
t.Equal(&db.Profile{
|
||||
Uuid: "mock-mojang-uuid",
|
||||
Username: "mOcK",
|
||||
}, foundProfile)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
suite.Run(t, new(CombinedProfilesProviderSuite))
|
||||
}
|
||||
|
||||
func createMojangProfile(withSkin bool, withCape bool) *mojang.ProfileResponse {
|
||||
timeZone, _ := time.LoadLocation("Europe/Warsaw")
|
||||
textures := &mojang.TexturesProp{
|
||||
Timestamp: utils.UnixMillisecond(time.Date(2024, 1, 29, 13, 34, 12, 0, timeZone)),
|
||||
ProfileID: "mock-mojang-uuid",
|
||||
ProfileName: "mOcK",
|
||||
Textures: &mojang.TexturesResponse{},
|
||||
}
|
||||
|
||||
if withSkin {
|
||||
textures.Textures.Skin = &mojang.SkinTexturesResponse{
|
||||
Url: "https://mojang/skin.png",
|
||||
Metadata: &mojang.SkinTexturesMetadata{
|
||||
Model: "slim",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if withCape {
|
||||
textures.Textures.Cape = &mojang.CapeTexturesResponse{
|
||||
Url: "https://mojang/cape.png",
|
||||
}
|
||||
}
|
||||
|
||||
response := &mojang.ProfileResponse{
|
||||
Id: textures.ProfileID,
|
||||
Name: textures.ProfileName,
|
||||
Props: []*mojang.Property{
|
||||
{
|
||||
Name: "textures",
|
||||
Value: mojang.EncodeTextures(textures),
|
||||
Signature: "mojang signature",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
106
internal/security/jwt.go
Normal file
106
internal/security/jwt.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"ely.by/chrly/internal/version"
|
||||
)
|
||||
|
||||
var now = time.Now
|
||||
var signingMethod = jwt.SigningMethodHS256
|
||||
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ProfilesScope Scope = "profiles"
|
||||
SignScope Scope = "sign"
|
||||
)
|
||||
|
||||
var validScopes = []Scope{
|
||||
ProfilesScope,
|
||||
SignScope,
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Scopes []Scope `json:"scopes"`
|
||||
}
|
||||
|
||||
func NewJwt(key []byte) *Jwt {
|
||||
return &Jwt{
|
||||
Key: key,
|
||||
}
|
||||
}
|
||||
|
||||
type Jwt struct {
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func (t *Jwt) NewToken(scopes ...Scope) (string, error) {
|
||||
if len(scopes) == 0 {
|
||||
return "", errors.New("you must specify at least one scope")
|
||||
}
|
||||
|
||||
for _, scope := range scopes {
|
||||
if !slices.Contains(validScopes, scope) {
|
||||
return "", fmt.Errorf("unknown scope %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
token := jwt.New(signingMethod)
|
||||
token.Claims = &claims{
|
||||
jwt.RegisteredClaims{
|
||||
Issuer: "chrly",
|
||||
IssuedAt: jwt.NewNumericDate(now()),
|
||||
},
|
||||
scopes,
|
||||
}
|
||||
token.Header["v"] = version.MajorVersion
|
||||
|
||||
return token.SignedString(t.Key)
|
||||
}
|
||||
|
||||
// Keep those names generic in order to reuse them in future for alternative authentication methods
|
||||
var MissingAuthenticationError = errors.New("authentication value not provided")
|
||||
var InvalidTokenError = errors.New("passed authentication value is invalid")
|
||||
|
||||
func (t *Jwt) Authenticate(req *http.Request, scope Scope) error {
|
||||
bearerToken := req.Header.Get("Authorization")
|
||||
if bearerToken == "" {
|
||||
return MissingAuthenticationError
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(strings.ToLower(bearerToken), "bearer ") {
|
||||
return InvalidTokenError
|
||||
}
|
||||
|
||||
tokenStr := bearerToken[7:] // trim "bearer " part
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return t.Key, nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Join(InvalidTokenError, err)
|
||||
}
|
||||
|
||||
if _, vHeaderExists := token.Header["v"]; !vHeaderExists {
|
||||
return errors.Join(InvalidTokenError, errors.New("missing v header"))
|
||||
}
|
||||
|
||||
claims := token.Claims.(*claims)
|
||||
if !slices.Contains(claims.Scopes, scope) {
|
||||
return errors.New("the token doesn't have the scope to perform the action")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
88
internal/security/jwt_test.go
Normal file
88
internal/security/jwt_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const jwtString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInYiOjV9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIl19.LrXrKo5iRFFHCDlMsVDhmJJheZqxbxuEVXB4XswHFKY"
|
||||
|
||||
func TestJwtAuth_NewToken(t *testing.T) {
|
||||
jwt := NewJwt([]byte("secret"))
|
||||
now = func() time.Time {
|
||||
return time.Date(2024, 2, 1, 11, 26, 15, 0, time.UTC)
|
||||
}
|
||||
|
||||
t.Run("with known scope", func(t *testing.T) {
|
||||
token, err := jwt.NewToken(ProfilesScope, SignScope)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInYiOjV9.eyJpc3MiOiJjaHJseSIsImlhdCI6MTcwNjc4Njc3NSwic2NvcGVzIjpbInByb2ZpbGVzIiwic2lnbiJdfQ.HkNGiDba3I_bLGN6sF0eTE5n6rMLgYfAZZEqI4xb2X4", token)
|
||||
})
|
||||
|
||||
t.Run("with unknown scope", func(t *testing.T) {
|
||||
token, err := jwt.NewToken("scope-123")
|
||||
require.ErrorContains(t, err, "unknown")
|
||||
require.Empty(t, token)
|
||||
})
|
||||
|
||||
t.Run("no scopes", func(t *testing.T) {
|
||||
token, err := jwt.NewToken()
|
||||
require.Error(t, err)
|
||||
require.Empty(t, token)
|
||||
})
|
||||
}
|
||||
|
||||
func TestJwtAuth_Authenticate(t *testing.T) {
|
||||
jwt := NewJwt([]byte("secret"))
|
||||
t.Run("success", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
req.Header.Add("Authorization", "Bearer "+jwtString)
|
||||
err := jwt.Authenticate(req, ProfilesScope)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("has no required scope", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
req.Header.Add("Authorization", "Bearer "+jwtString)
|
||||
err := jwt.Authenticate(req, SignScope)
|
||||
require.ErrorContains(t, err, "scope")
|
||||
})
|
||||
|
||||
t.Run("request without auth header", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
err := jwt.Authenticate(req, ProfilesScope)
|
||||
require.ErrorIs(t, err, MissingAuthenticationError)
|
||||
})
|
||||
|
||||
t.Run("no bearer token prefix", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
req.Header.Add("Authorization", "trash")
|
||||
err := jwt.Authenticate(req, ProfilesScope)
|
||||
require.ErrorIs(t, err, InvalidTokenError)
|
||||
})
|
||||
|
||||
t.Run("bearer token but not jwt", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
req.Header.Add("Authorization", "Bearer seems.like.jwt")
|
||||
err := jwt.Authenticate(req, ProfilesScope)
|
||||
require.ErrorIs(t, err, InvalidTokenError)
|
||||
})
|
||||
|
||||
t.Run("invalid signature", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
req.Header.Add("Authorization", "Bearer "+jwtString+"123")
|
||||
err := jwt.Authenticate(req, ProfilesScope)
|
||||
require.ErrorIs(t, err, InvalidTokenError)
|
||||
})
|
||||
|
||||
t.Run("missing v header", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "http://localhost", nil)
|
||||
req.Header.Add("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIl19.zOX2ZKyU37kjwt1p9uCHxALxWQD2UC0wWcAcNvBXGq0")
|
||||
err := jwt.Authenticate(req, ProfilesScope)
|
||||
require.ErrorIs(t, err, InvalidTokenError)
|
||||
require.ErrorContains(t, err, "missing v header")
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user