mirror of
https://github.com/elyby/chrly.git
synced 2025-05-31 14:11:51 +05:30
Compare commits
337 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 | ||
|
|
e33b86b809 | ||
|
|
80fa307915 | ||
|
|
2e9520db89 | ||
|
|
74564b4747 | ||
|
|
18909776a8 | ||
|
|
d1b1f22a93 | ||
|
|
cb928a3918 | ||
|
|
d9aeaba627 | ||
|
|
645f6ac694 | ||
|
|
eab7c6ecaa | ||
|
|
ac714de8df | ||
|
|
8007b082d6 | ||
|
|
9cb6502f9c | ||
|
|
76a3f3ad26 | ||
|
|
bdd7c5e15e | ||
|
|
340b24d862 | ||
|
|
cf99a0eab2 | ||
|
|
fb4ae46e29 | ||
|
|
971155485b | ||
|
|
9ee3e93042 | ||
|
|
6128c56a0c | ||
|
|
a2e3d28580 | ||
|
|
fecfa9c4e8 | ||
|
|
04714543b8 | ||
|
|
ec461efe34 | ||
|
|
eec6b384b7 | ||
|
|
4734bfd93c | ||
|
|
b1dbee2310 | ||
|
|
78917a70d3 | ||
|
|
4bf146dd43 | ||
|
|
06b8e88346 | ||
|
|
4945b3f984 | ||
|
|
359aef4b40 | ||
|
|
b159cd327c | ||
|
|
b99697d26e | ||
|
|
d51c358ef6 | ||
|
|
d9629b5e83 | ||
|
|
428bedf301 | ||
|
|
11a7570f51 | ||
|
|
676ba03c37 | ||
|
|
07903cf9c8 | ||
|
|
e090d04dc7 | ||
|
|
a993c1d157 | ||
|
|
a661f9aac3 | ||
|
|
9ffdf99b77 | ||
|
|
ad35872fc1 | ||
|
|
a8d8fffaa5 | ||
|
|
0d41f0c347 | ||
|
|
b22f0551fa | ||
|
|
1a906cfc09 | ||
|
|
f610667aa5 | ||
|
|
8b51c1bd0c | ||
|
|
cbe940f8ec | ||
|
|
8693673a71 | ||
|
|
73205648d2 | ||
|
|
3d73cc9402 | ||
|
|
39f5ec5bee | ||
|
|
e652691b29 | ||
|
|
d3b4bee3b0 | ||
|
|
ae50e90ea7 | ||
|
|
c74151c558 | ||
|
|
445bd18fbc | ||
|
|
6a881a62e3 | ||
|
|
201a257d69 | ||
|
|
5d46094643 | ||
|
|
1694403c79 | ||
|
|
66c61dc3cd | ||
|
|
a0d940f8cd | ||
|
|
58a1c6ec33 | ||
|
|
34179ae1fe | ||
|
|
6a54af62aa | ||
|
|
e05c5f200c | ||
|
|
9c4930a0be | ||
|
|
aab7ba9517 | ||
|
|
dea674f52e | ||
|
|
e8a7008e11 | ||
|
|
9467911025 | ||
|
|
a9acfb954f | ||
|
|
98b787fa99 | ||
|
|
4bcd0495ed | ||
|
|
e03832b4e8 | ||
|
|
0d6ca356d1 | ||
|
|
2477433dc9 | ||
|
|
3e3ba296d5 | ||
|
|
e8bd90d8d9 | ||
|
|
408d411846 |
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 }}
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,6 +1,11 @@
|
||||
# IDEA
|
||||
/.idea
|
||||
# IDE files
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode
|
||||
|
||||
# Docker Compose file
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
# Go mod vendoring
|
||||
/vendor
|
||||
|
||||
# Local environment
|
||||
/docker-compose.yml
|
||||
/data
|
||||
|
||||
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
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,18 +0,0 @@
|
||||
FROM golang:1.7
|
||||
|
||||
RUN mkdir -p /go/src/elyby/minecraft-skinsystem \
|
||||
&& ln -s /go/src/elyby/minecraft-skinsystem /go/src/app
|
||||
|
||||
WORKDIR /go/src/app
|
||||
|
||||
COPY ./minecraft-skinsystem.go /go/src/app/
|
||||
COPY ./lib /go/src/app/lib
|
||||
|
||||
RUN go-wrapper download
|
||||
RUN go-wrapper install
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
VOLUME ["/go/src/app"]
|
||||
|
||||
CMD ["go-wrapper", "run"]
|
||||
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.
|
||||
463
README.md
463
README.md
@@ -1,38 +1,459 @@
|
||||
# Это заготовка для нормального файла
|
||||
# Chrly
|
||||
|
||||
Для настройки Dev-окружения нужно склонировать проект в удобное место,
|
||||
за тем сделать символьную ссылку в свой GOPATH:
|
||||
[![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)
|
||||
|
||||
```sh
|
||||
# Выполнять, находясь внутри директории репозитория
|
||||
mkdir -p $GOPATH/src/elyby
|
||||
ln -s $PWD $GOPATH/src/elyby/minecraft-skinsystem
|
||||
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.
|
||||
|
||||
## Installation
|
||||
|
||||
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
|
||||
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY`
|
||||
environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
|
||||
below.
|
||||
|
||||
```yml
|
||||
version: '2'
|
||||
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
|
||||
git clone git@bitbucket.org:elyby/minecraft-skinsystem.git $GOPATH/src/elyby/minecraft-skinsystem
|
||||
openssl genrsa 4096 | base64 -w0
|
||||
```
|
||||
|
||||
Нужно скопировать правильный docker-compose файл для желаемого окружения:
|
||||
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.
|
||||
|
||||
### Config
|
||||
|
||||
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
|
||||
cp docker-compose.dev.yml docker-compose.yml # dev env
|
||||
cp docker-compose.prod.yml docker-compose.yml # prod env
|
||||
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
|
||||
```
|
||||
|
||||
Если нужно пересобрать весь контейнер, то выполняем следующее:
|
||||
If your Redis instance isn't located at the `localhost`, you can change host by editing environment variable
|
||||
`STORAGE_REDIS_HOST`.
|
||||
|
||||
```
|
||||
docker-compose stop app # Останавливаем конейтнер, если он ещё работает
|
||||
docker-compose rm -f app # Удаляем конейтнер
|
||||
docker-compose build app # Запускаем билд по новой
|
||||
docker-compose up -d app # Поднимаем свежесобранный контейнер обратно
|
||||
```
|
||||
After all of that `go run main.go serve` should successfully start the application.
|
||||
To run tests execute `go test ./...`.
|
||||
|
||||
[ico-lang]: https://img.shields.io/github/go-mod/go-version/elyby/chrly?style=flat-square
|
||||
[ico-build]: https://img.shields.io/github/actions/workflow/status/elyby/chrly/build.yml?style=flat-square
|
||||
[ico-coverage]: https://img.shields.io/codecov/c/github/elyby/chrly.svg?style=flat-square
|
||||
[ico-changelog]: https://img.shields.io/badge/keep%20a-changelog-orange.svg?style=flat-square
|
||||
[ico-license]: https://img.shields.io/github/license/elyby/chrly.svg?style=flat-square
|
||||
|
||||
[link-go]: https://golang.org
|
||||
[link-build]: https://github.com/elyby/chrly/actions
|
||||
[link-coverage]: https://codecov.io/gh/elyby/chrly
|
||||
|
||||
12
build/package/Dockerfile
Normal file
12
build/package/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
ARG BINARY
|
||||
|
||||
FROM scratch
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY ${BINARY} /usr/local/bin/chrly
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/chrly"]
|
||||
CMD ["serve"]
|
||||
16
cmd/chrly/chrly.go
Normal file
16
cmd/chrly/chrly.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
. "ely.by/chrly/internal/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := RootCmd.Execute()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
2
data/redis/.gitignore
vendored
2
data/redis/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
*
|
||||
!.gitignore
|
||||
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,10 +0,0 @@
|
||||
version: '2'
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "80:80"
|
||||
|
||||
redis:
|
||||
image: redis:3.0
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
@@ -1,17 +0,0 @@
|
||||
version: '2'
|
||||
services:
|
||||
app:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: app
|
||||
build: .
|
||||
volumes:
|
||||
- ./:/go/src/app
|
||||
command: ["go", "run", "minecraft-skinsystem.go"]
|
||||
links:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: redis
|
||||
@@ -1,16 +0,0 @@
|
||||
version: '2'
|
||||
services:
|
||||
app:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: app
|
||||
image: erickskrauch/ely-by-skinsystem:master
|
||||
links:
|
||||
- redis
|
||||
restart: always
|
||||
|
||||
redis:
|
||||
extends:
|
||||
file: docker-compose.base.yml
|
||||
service: redis
|
||||
restart: always
|
||||
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=
|
||||
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")
|
||||
})
|
||||
}
|
||||
47
internal/utils/queue.go
Normal file
47
internal/utils/queue.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Queue[T any] struct {
|
||||
lock sync.Mutex
|
||||
items []T
|
||||
}
|
||||
|
||||
func NewQueue[T any]() *Queue[T] {
|
||||
return &Queue[T]{
|
||||
items: []T{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Queue[T]) Enqueue(item T) int {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
s.items = append(s.items, item)
|
||||
|
||||
return len(s.items)
|
||||
}
|
||||
|
||||
func (s *Queue[T]) Dequeue(n int) ([]T, int) {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
l := len(s.items)
|
||||
if n > l {
|
||||
n = l
|
||||
}
|
||||
|
||||
items := s.items[0:n]
|
||||
s.items = s.items[n:l]
|
||||
|
||||
return items, l - n
|
||||
}
|
||||
|
||||
func (s *Queue[T]) Len() int {
|
||||
s.lock.Lock()
|
||||
defer s.lock.Unlock()
|
||||
|
||||
return len(s.items)
|
||||
}
|
||||
47
internal/utils/queue_test.go
Normal file
47
internal/utils/queue_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQueue(t *testing.T) {
|
||||
t.Run("Enqueue", func(t *testing.T) {
|
||||
s := NewQueue[string]()
|
||||
require.Equal(t, 1, s.Enqueue("username1"))
|
||||
require.Equal(t, 2, s.Enqueue("username2"))
|
||||
require.Equal(t, 3, s.Enqueue("username3"))
|
||||
})
|
||||
|
||||
t.Run("Dequeue", func(t *testing.T) {
|
||||
s := NewQueue[string]()
|
||||
s.Enqueue("username1")
|
||||
s.Enqueue("username2")
|
||||
s.Enqueue("username3")
|
||||
s.Enqueue("username4")
|
||||
s.Enqueue("username5")
|
||||
|
||||
items, queueLen := s.Dequeue(2)
|
||||
require.Len(t, items, 2)
|
||||
require.Equal(t, 3, queueLen)
|
||||
require.Equal(t, "username1", items[0])
|
||||
require.Equal(t, "username2", items[1])
|
||||
|
||||
items, queueLen = s.Dequeue(40)
|
||||
require.Len(t, items, 3)
|
||||
require.Equal(t, 0, queueLen)
|
||||
require.Equal(t, "username3", items[0])
|
||||
require.Equal(t, "username4", items[1])
|
||||
require.Equal(t, "username5", items[2])
|
||||
})
|
||||
|
||||
t.Run("Len", func(t *testing.T) {
|
||||
s := NewQueue[string]()
|
||||
s.Enqueue("username1")
|
||||
s.Enqueue("username2")
|
||||
s.Enqueue("username3")
|
||||
|
||||
require.Equal(t, 3, s.Len())
|
||||
})
|
||||
}
|
||||
7
internal/utils/time.go
Normal file
7
internal/utils/time.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package utils
|
||||
|
||||
import "time"
|
||||
|
||||
func UnixMillisecond(t time.Time) int64 {
|
||||
return t.UnixNano() / int64(time.Millisecond)
|
||||
}
|
||||
16
internal/utils/time_test.go
Normal file
16
internal/utils/time_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"testing"
|
||||
|
||||
assert "github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnixMillisecond(t *testing.T) {
|
||||
loc, _ := time.LoadLocation("CET")
|
||||
d := time.Date(2021, 02, 26, 00, 43, 57, 987654321, loc)
|
||||
|
||||
assert.Equal(t, int64(1614296637987), UnixMillisecond(d))
|
||||
}
|
||||
16
internal/version/version.go
Normal file
16
internal/version/version.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package version
|
||||
|
||||
const MajorVersion = 5
|
||||
|
||||
var (
|
||||
version = "undefined"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
func Version() string {
|
||||
return version
|
||||
}
|
||||
|
||||
func Commit() string {
|
||||
return commit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package data
|
||||
|
||||
import "fmt"
|
||||
|
||||
type DataNotFound struct {
|
||||
Who string
|
||||
}
|
||||
|
||||
func (e DataNotFound) Error() string {
|
||||
return fmt.Sprintf("Skin data not found. Required username \"%v\"", e.Who)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"log"
|
||||
"encoding/json"
|
||||
|
||||
"elyby/minecraft-skinsystem/lib/services"
|
||||
"elyby/minecraft-skinsystem/lib/tools"
|
||||
|
||||
"github.com/mediocregopher/radix.v2/redis"
|
||||
)
|
||||
|
||||
type SkinItem struct {
|
||||
UserId int `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
SkinId int `json:"skinId"`
|
||||
Url string `json:"url"`
|
||||
Is1_8 bool `json:"is1_8"`
|
||||
IsSlim bool `json:"isSlim"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func (s *SkinItem) Save() {
|
||||
str, _ := json.Marshal(s)
|
||||
services.RedisPool.Cmd("SET", tools.BuildKey(s.Username), str)
|
||||
}
|
||||
|
||||
func FindRecord(username string) (SkinItem, error) {
|
||||
var record SkinItem;
|
||||
response := services.RedisPool.Cmd("GET", tools.BuildKey(username));
|
||||
if (response.IsType(redis.Nil)) {
|
||||
return record, DataNotFound{username}
|
||||
}
|
||||
|
||||
result, err := response.Str()
|
||||
if (err == nil) {
|
||||
decodeErr := json.Unmarshal([]byte(result), &record)
|
||||
if (decodeErr != nil) {
|
||||
log.Println("Cannot decode record data")
|
||||
}
|
||||
}
|
||||
|
||||
return record, err
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package data
|
||||
|
||||
type TexturesResponse struct {
|
||||
Skin *Skin `json:"SKIN"`
|
||||
}
|
||||
|
||||
type Skin struct {
|
||||
Url string `json:"url"`
|
||||
Hash string `json:"hash"`
|
||||
Metadata *SkinMetadata `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type SkinMetadata struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"elyby/minecraft-skinsystem/lib/tools"
|
||||
)
|
||||
|
||||
func Cape(w http.ResponseWriter, r *http.Request) {
|
||||
username := tools.ParseUsername(mux.Vars(r)["username"])
|
||||
log.Println("request cape for username " + username)
|
||||
http.Redirect(w, r, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
|
||||
}
|
||||
|
||||
func CapeGET(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("name")
|
||||
if username == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mux.Vars(r)["username"] = username
|
||||
Cape(w, r)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Метод-наследие от первой версии системы скинов.
|
||||
// Всё ещё иногда используется
|
||||
// Просто конвертируем данные и отправляем их в основной обработчик
|
||||
func MinecraftPHP(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("name")
|
||||
required := r.URL.Query().Get("type")
|
||||
if username == "" || required == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mux.Vars(r)["username"] = username
|
||||
switch required {
|
||||
case "skin": Skin(w, r)
|
||||
case "cloack": Cape(w, r)
|
||||
default: {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
json, _ := json.Marshal(map[string]string{
|
||||
"status": "404",
|
||||
"message": "Not Found",
|
||||
"link": "http://docs.ely.by/skin-system.html",
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write(json)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"elyby/minecraft-skinsystem/lib/data"
|
||||
)
|
||||
|
||||
func SetSkin(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("X-Ely-key")
|
||||
if key != "43fd2ce61b3f5704dfd729c1f2d6ffdb" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("Nice try"))
|
||||
return
|
||||
}
|
||||
|
||||
skin := new(data.SkinItem)
|
||||
skin.Username = r.PostFormValue("username")
|
||||
skin.UserId, _ = strconv.Atoi(r.PostFormValue("userId"))
|
||||
skin.SkinId, _ = strconv.Atoi(r.PostFormValue("skinId"))
|
||||
skin.Hash = r.PostFormValue("hash")
|
||||
skin.Is1_8, _ = strconv.ParseBool(r.PostFormValue("is1_8"))
|
||||
skin.IsSlim, _ = strconv.ParseBool(r.PostFormValue("isSlim"))
|
||||
skin.Url = r.PostFormValue("url")
|
||||
skin.Save()
|
||||
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"elyby/minecraft-skinsystem/lib/tools"
|
||||
"elyby/minecraft-skinsystem/lib/data"
|
||||
)
|
||||
|
||||
func Skin(w http.ResponseWriter, r *http.Request) {
|
||||
username := tools.ParseUsername(mux.Vars(r)["username"])
|
||||
log.Println("request skin for username " + username);
|
||||
rec, err := data.FindRecord(username)
|
||||
if (err != nil) {
|
||||
http.Redirect(w, r, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, tools.BuildElyUrl(rec.Url), 301);
|
||||
}
|
||||
|
||||
func SkinGET(w http.ResponseWriter, r *http.Request) {
|
||||
username := r.URL.Query().Get("name")
|
||||
if username == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mux.Vars(r)["username"] = username
|
||||
Skin(w, r)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"elyby/minecraft-skinsystem/lib/data"
|
||||
"elyby/minecraft-skinsystem/lib/tools"
|
||||
)
|
||||
|
||||
func Textures(w http.ResponseWriter, r *http.Request) {
|
||||
username := tools.ParseUsername(mux.Vars(r)["username"])
|
||||
log.Println("request textures for username " + username)
|
||||
|
||||
rec, err := data.FindRecord(username)
|
||||
if (err != nil || rec.SkinId == 0) {
|
||||
rec.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png"
|
||||
rec.Hash = string(tools.BuildNonElyTexturesHash(username))
|
||||
} else {
|
||||
rec.Url = tools.BuildElyUrl(rec.Url)
|
||||
}
|
||||
|
||||
textures := data.TexturesResponse{
|
||||
Skin: &data.Skin{
|
||||
Url: rec.Url,
|
||||
Hash: rec.Hash,
|
||||
},
|
||||
}
|
||||
|
||||
if (rec.IsSlim) {
|
||||
textures.Skin.Metadata = &data.SkinMetadata{
|
||||
Model: "slim",
|
||||
}
|
||||
}
|
||||
|
||||
response,_ := json.Marshal(textures)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(response)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/mediocregopher/radix.v2/pool"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var RedisPool *pool.Pool
|
||||
|
||||
var Router *mux.Router
|
||||
@@ -1,40 +0,0 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"crypto/md5"
|
||||
"strconv"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func ParseUsername(username string) string {
|
||||
const suffix = ".png"
|
||||
if strings.HasSuffix(username, suffix) {
|
||||
username = strings.TrimSuffix(username, suffix)
|
||||
}
|
||||
|
||||
return username
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func BuildKey(username string) string {
|
||||
return "username:" + strings.ToLower(username)
|
||||
}
|
||||
|
||||
func BuildElyUrl(route string) string {
|
||||
return "http://ely.by" + route
|
||||
}
|
||||
|
||||
func getCurrentHour() int64 {
|
||||
n := time.Now()
|
||||
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), 0, 0, 0, time.UTC).Unix()
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package tools_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
. "elyby/minecraft-skinsystem/lib/tools"
|
||||
)
|
||||
|
||||
func TestParseUsername(t *testing.T) {
|
||||
if ParseUsername("test.png") != "test" {
|
||||
t.Error("Function should trim .png at end")
|
||||
}
|
||||
|
||||
if ParseUsername("test") != "test" {
|
||||
t.Error("Function should return string itself, if it not contains .png at end")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKey(t *testing.T) {
|
||||
if BuildKey("Test") != "username:test" {
|
||||
t.Error("Function shound convert string to lower case and concatenate it with usernmae:")
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"runtime"
|
||||
//"time"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/mediocregopher/radix.v2/pool"
|
||||
|
||||
"elyby/minecraft-skinsystem/lib/routes"
|
||||
"elyby/minecraft-skinsystem/lib/services"
|
||||
//"github.com/mediocregopher/radix.v2/redis"
|
||||
)
|
||||
|
||||
const redisString string = "redis:6379"
|
||||
|
||||
func main() {
|
||||
log.Println("Starting...")
|
||||
|
||||
runtime.GOMAXPROCS(runtime.NumCPU())
|
||||
|
||||
log.Println("Connecting to redis")
|
||||
redisPool, redisErr := pool.New("tcp", redisString, 10)
|
||||
if redisErr != nil {
|
||||
log.Fatal("Redis unavailable")
|
||||
}
|
||||
log.Println("Connected to redis")
|
||||
|
||||
router := mux.NewRouter().StrictSlash(true)
|
||||
router.HandleFunc("/skins/{username}", routes.Skin).Methods("GET").Name("skins")
|
||||
router.HandleFunc("/cloaks/{username}", routes.Cape).Methods("GET").Name("cloaks")
|
||||
router.HandleFunc("/textures/{username}", routes.Textures).Methods("GET").Name("textures")
|
||||
// Legacy
|
||||
router.HandleFunc("/minecraft.php", routes.MinecraftPHP).Methods("GET")
|
||||
router.HandleFunc("/skins/", routes.SkinGET).Methods("GET")
|
||||
router.HandleFunc("/cloaks/", routes.CapeGET).Methods("GET")
|
||||
// 404
|
||||
router.NotFoundHandler = http.HandlerFunc(routes.NotFound)
|
||||
|
||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||
apiRouter.HandleFunc("/user/{username}/skin", routes.SetSkin).Methods("POST")
|
||||
|
||||
services.RedisPool = redisPool
|
||||
services.Router = router
|
||||
|
||||
/*go func() {
|
||||
for {
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
resp := services.Redis.Cmd("PING")
|
||||
if (resp.Err != nil) {
|
||||
log.Println("Redis not pinged. Try to reconnect")
|
||||
newClient, redisErr := redis.Dial("tcp", redisString)
|
||||
if (redisErr != nil) {
|
||||
log.Println("Cannot reconnect to redis")
|
||||
} else {
|
||||
services.Redis = newClient
|
||||
log.Println("Reconnected")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()*/
|
||||
|
||||
log.Println("Started");
|
||||
log.Fatal(http.ListenAndServe(":80", router))
|
||||
}
|
||||
Reference in New Issue
Block a user