Compare commits

...

56 Commits

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

View File

@@ -1,13 +0,0 @@
version: 2
cli:
server: https://app.fossa.com
fetcher: git
project: github.com:elyby/chrly
analyze:
modules:
- name: chrly
type: go
target: github.com/elyby/chrly
path: .
options:
strategy: dep

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

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

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

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

View File

@@ -1,63 +0,0 @@
os: linux
dist: xenial
language: go
go:
- "1.14"
stages:
- Tests
- name: Deploy
if: env(TRAVIS_PULL_REQUEST) IS false AND (branch = master OR tag IS present) AND commit_message !~ /(\[skip deploy\])/
install:
- go get -u github.com/golang/dep/cmd/dep
- dep ensure
cache:
directories:
- $GOPATH/pkg/dep
jobs:
include:
# Tests stage
- name: Unit tests
stage: Tests
services:
- redis
script:
- go test -v -race --tags redis -coverprofile=coverage.txt -covermode=atomic ./...
- bash <(curl -s https://codecov.io/bash)
- name: FOSSA
stage: Tests
if: branch = master
before_script:
- curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash
script:
- fossa init
- fossa analyze
# Disable until https://github.com/fossas/fossa-cli/issues/596 will be resolved
# - fossa test
# Deploy stage
- name: Docker image
stage: Deploy
services:
- docker
script:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
- export DOCKER_TAG="${TRAVIS_TAG:-dev}"
- export APP_VERSION="${TRAVIS_TAG:-dev-${TRAVIS_COMMIT:0:7}}"
- >
env CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build
-o release/chrly
-ldflags "-extldflags '-static' -X github.com/elyby/chrly/version.version=$APP_VERSION -X github.com/elyby/chrly/version.commit=$TRAVIS_COMMIT"
main.go
- docker build -t elyby/chrly:$DOCKER_TAG .
- docker push elyby/chrly:$DOCKER_TAG
- |
if [ ! -z ${TRAVIS_TAG:+x} ]; then
docker tag elyby/chrly:$DOCKER_TAG elyby/chrly:latest
docker push elyby/chrly:latest
fi

View File

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

View File

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

359
Gopkg.lock generated
View File

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

View File

@@ -1,60 +0,0 @@
ignored = ["github.com/elyby/chrly"]
[[constraint]]
name = "github.com/gorilla/mux"
version = "^1.6.1"
[[constraint]]
name = "github.com/mediocregopher/radix.v2"
branch = "master"
[[constraint]]
name = "github.com/mono83/slf"
branch = "master"
[[constraint]]
name = "github.com/spf13/cobra"
version = "^0.0.3"
[[constraint]]
name = "github.com/spf13/viper"
version = "^1.0.0"
[[constraint]]
name = "github.com/getsentry/raven-go"
branch = "master"
[[constraint]]
name = "github.com/SermoDigital/jose"
version = "~1.1.0"
[[constraint]]
name = "github.com/thedevsaddam/govalidator"
version = "^1.9.6"
[[constraint]]
name = "github.com/tevino/abool"
branch = "master"
[[constraint]]
name = "github.com/asaskevich/EventBus"
source = "https://github.com/erickskrauch/EventBus.git"
branch = "publish_nil_values"
[[constraint]]
name = "github.com/etherlabsio/healthcheck"
version = "2.0.3"
[[constraint]]
name = "github.com/goava/di"
branch = "master"
# Testing dependencies
[[constraint]]
name = "github.com/stretchr/testify"
version = "^1.3.0"
[[constraint]]
name = "github.com/h2non/gock"
version = "^1.0.6"

View File

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

145
README.md
View File

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

View File

@@ -7,25 +7,29 @@ import (
"io/ioutil"
"net/http"
"strings"
"sync"
"time"
)
var HttpClient = &http.Client{
Timeout: 3 * time.Second,
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: 1024,
},
}
type SignedTexturesResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
once sync.Once
decodedTextures *TexturesProp
decodedErr error
}
func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
if t.decodedTextures == nil {
func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) {
t.once.Do(func() {
var texturesProp string
for _, prop := range t.Props {
if prop.Name == "textures" {
@@ -35,14 +39,18 @@ func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
}
if texturesProp == "" {
return nil
return
}
decodedTextures, _ := DecodeTextures(texturesProp)
t.decodedTextures = decodedTextures
}
decodedTextures, err := DecodeTextures(texturesProp)
if err != nil {
t.decodedErr = err
} else {
t.decodedTextures = decodedTextures
}
})
return t.decodedTextures
return t.decodedTextures, t.decodedErr
}
type Property struct {
@@ -58,11 +66,17 @@ type ProfileInfo struct {
IsDemo bool `json:"demo,omitempty"`
}
var ApiMojangDotComAddr = "https://api.mojang.com"
var SessionServerMojangComAddr = "https://sessionserver.mojang.com"
// Exchanges usernames array to array of uuids
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
requestBody, _ := json.Marshal(usernames)
request, _ := http.NewRequest("POST", "https://api.mojang.com/profiles/minecraft", bytes.NewBuffer(requestBody))
request, err := http.NewRequest("POST", ApiMojangDotComAddr+"/profiles/minecraft", bytes.NewBuffer(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
@@ -88,12 +102,15 @@ func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + normalizedUuid
url := SessionServerMojangComAddr + "/session/minecraft/profile/" + normalizedUuid
if signed {
url += "?unsigned=false"
}
request, _ := http.NewRequest("GET", url, nil)
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
response, err := HttpClient.Do(request)
if err != nil {
@@ -149,7 +166,7 @@ type EmptyResponse struct {
}
func (*EmptyResponse) Error() string {
return "200: Empty Response"
return "204: Empty Response"
}
func (*EmptyResponse) IsMojangError() bool {

View File

@@ -5,6 +5,7 @@ import (
"testing"
"github.com/h2non/gock"
testify "github.com/stretchr/testify/assert"
)
@@ -20,7 +21,8 @@ func TestSignedTexturesResponse(t *testing.T) {
},
},
}
textures := obj.DecodeTextures()
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
})
@@ -30,7 +32,8 @@ func TestSignedTexturesResponse(t *testing.T) {
Name: "mock",
Props: []*Property{},
}
textures := obj.DecodeTextures()
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Nil(t, textures)
})
}
@@ -258,7 +261,7 @@ func TestUuidToTextures(t *testing.T) {
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&EmptyResponse{}, err)
assert.EqualError(err, "200: Empty Response")
assert.EqualError(err, "204: Empty Response")
assert.Implements((*ResponseError)(nil), err)
})

View File

@@ -6,7 +6,7 @@ import (
"os"
"strings"
. "github.com/goava/di"
. "github.com/defval/di"
"github.com/spf13/cobra"
"github.com/spf13/viper"

56
cmd/root_profiling.go Normal file
View File

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

View File

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

View File

@@ -1,34 +1,47 @@
// +build redis
//go:build redis
package redis
import (
"context"
"fmt"
"reflect"
"os"
"strconv"
"testing"
"time"
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix/v4"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
const redisAddr = "localhost:6379"
var redisAddr string
func init() {
host := "localhost"
port := 6379
if os.Getenv("STORAGE_REDIS_HOST") != "" {
host = os.Getenv("STORAGE_REDIS_HOST")
}
if os.Getenv("STORAGE_REDIS_PORT") != "" {
port, _ = strconv.Atoi(os.Getenv("STORAGE_REDIS_PORT"))
}
redisAddr = fmt.Sprintf("%s:%d", host, port)
}
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(redisAddr, 12)
conn, err := New(context.Background(), redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
internalPool := reflect.ValueOf(conn.pool).Elem().FieldByName("pool")
assert.Equal(t, 12, internalPool.Cap())
})
t.Run("should return error", func(t *testing.T) {
conn, err := New("localhost:12345", 12) // Use localhost to avoid DNS resolution
conn, err := New(context.Background(), "localhost:12345", 12) // Use localhost to avoid DNS resolution
assert.Error(t, err)
assert.Nil(t, conn)
})
@@ -39,21 +52,30 @@ type redisTestSuite struct {
Redis *Redis
cmd func(cmd string, args ...interface{}) *redis.Resp
cmd func(cmd string, args ...interface{}) string
}
func (suite *redisTestSuite) SetupSuite() {
conn, err := New(redisAddr, 10)
ctx := context.Background()
conn, err := New(ctx, redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
suite.Redis = conn
suite.cmd = conn.pool.Cmd
suite.cmd = func(cmd string, args ...interface{}) string {
var result string
err := suite.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
if err != nil {
panic(err)
}
return result
}
}
func (suite *redisTestSuite) SetupTest() {
// Cleanup database before the each test
// Cleanup database before each test
suite.cmd("FLUSHALL")
}
@@ -85,7 +107,7 @@ func TestRedis(t *testing.T) {
* mojangSignature: "mock-mojang-signature"
* }
*/
var skinRecord = []byte{
var skinRecord = string([]byte{
0x78, 0x9c, 0x5c, 0xce, 0x4b, 0x4a, 0x4, 0x41, 0xc, 0xc6, 0xf1, 0xbb, 0x7c, 0xeb, 0x1a, 0xdb, 0xd6, 0xb2,
0x9c, 0xc9, 0xd, 0x5c, 0x88, 0x8b, 0xd1, 0xb5, 0x84, 0x4e, 0xa6, 0xa7, 0xec, 0x7a, 0xc, 0xf5, 0x0, 0x41,
0xbc, 0xbb, 0xb4, 0xd2, 0xa, 0x2e, 0xf3, 0xe3, 0x9f, 0x90, 0xf, 0xf4, 0xaa, 0xe5, 0x41, 0x40, 0xa3, 0x41,
@@ -96,7 +118,7 @@ var skinRecord = []byte{
0xa0, 0x13, 0x87, 0xaa, 0x6, 0x31, 0xbf, 0x71, 0x9a, 0x9f, 0xf5, 0xbd, 0xf5, 0xa2, 0x15, 0x84, 0x98, 0xa7,
0x65, 0xf7, 0xa3, 0xbb, 0xb6, 0xf1, 0xd6, 0x1d, 0xfd, 0x9c, 0x78, 0xa5, 0x7f, 0x61, 0xfd, 0x75, 0x83, 0xa7,
0x20, 0x2f, 0x7f, 0xff, 0xe2, 0xf3, 0x2b, 0x0, 0x0, 0xff, 0xff, 0x6f, 0xdd, 0x51, 0x71,
}
})
func (suite *redisTestSuite) TestFindSkinByUsername() {
suite.RunSubTest("exists record", func() {
@@ -182,14 +204,11 @@ func (suite *redisTestSuite) TestSaveSkin() {
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().False(usernameResp.IsType(redis.Nil))
bytes, _ := usernameResp.Bytes()
suite.Require().Equal(skinRecord, bytes)
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal(skinRecord, usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().False(usernameResp.IsType(redis.Nil))
str, _ := idResp.Str()
suite.Require().Equal("Mock", str)
suite.Require().Equal("Mock", idResp)
})
suite.RunSubTest("save exists record with changed username", func() {
@@ -211,9 +230,8 @@ func (suite *redisTestSuite) TestSaveSkin() {
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:newmock")
suite.Require().False(usernameResp.IsType(redis.Nil))
bytes, _ := usernameResp.Bytes()
suite.Require().Equal([]byte{
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal(string([]byte{
0x78, 0x9c, 0x5c, 0x8e, 0xcb, 0x4e, 0xc3, 0x40, 0xc, 0x45, 0xff, 0xe5, 0xae, 0xa7, 0x84, 0x40, 0x18, 0x5a,
0xff, 0x1, 0xb, 0x60, 0x51, 0x58, 0x23, 0x2b, 0x76, 0xd3, 0x21, 0xf3, 0xa8, 0xe6, 0x21, 0x90, 0x10, 0xff,
0x8e, 0x52, 0x14, 0x90, 0xba, 0xf4, 0xd1, 0xf1, 0xd5, 0xf9, 0x42, 0x2b, 0x9a, 0x1f, 0x4, 0xd4, 0x1b, 0xb4,
@@ -225,15 +243,14 @@ func (suite *redisTestSuite) TestSaveSkin() {
0x42, 0x1a, 0xe7, 0xcd, 0x2f, 0xdd, 0xd4, 0x15, 0xaf, 0xde, 0xde, 0x4d, 0x91, 0x17, 0x74, 0x21, 0x96, 0x3f,
0x6e, 0xf0, 0xec, 0xe5, 0xf5, 0x3f, 0xf9, 0xdc, 0xfb, 0xfd, 0x13, 0x0, 0x0, 0xff, 0xff, 0xca, 0xc3, 0x54,
0x25,
}, bytes)
}), usernameResp)
oldUsernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(oldUsernameResp.IsType(redis.Nil))
suite.Require().Empty(oldUsernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().False(usernameResp.IsType(redis.Nil))
str, _ := idResp.Str()
suite.Require().Equal("NewMock", str)
suite.Require().NotEmpty(usernameResp)
suite.Require().Equal("NewMock", idResp)
})
}
@@ -246,10 +263,10 @@ func (suite *redisTestSuite) TestRemoveSkinByUserId() {
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
suite.Require().Empty(usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
suite.Require().Empty(idResp)
})
suite.RunSubTest("exists only id", func() {
@@ -259,7 +276,7 @@ func (suite *redisTestSuite) TestRemoveSkinByUserId() {
suite.Require().Nil(err)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
suite.Require().Empty(idResp)
})
suite.RunSubTest("error when querying skin record", func() {
@@ -280,10 +297,10 @@ func (suite *redisTestSuite) TestRemoveSkinByUsername() {
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
suite.Require().Empty(usernameResp)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
suite.Require().Empty(idResp)
})
suite.RunSubTest("exists only username", func() {
@@ -293,7 +310,7 @@ func (suite *redisTestSuite) TestRemoveSkinByUsername() {
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
suite.Require().Empty(usernameResp)
})
suite.RunSubTest("no records", func() {
@@ -317,15 +334,30 @@ func (suite *redisTestSuite) TestGetUuid() {
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().True(found)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("exists record with empty uuid value", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf(":%d", time.Now().Unix()),
)
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().True(found)
suite.Require().Empty("", uuid)
})
suite.RunSubTest("not exists record", func() {
uuid, err := suite.Redis.GetUuid("Mock")
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().False(found)
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
})
suite.RunSubTest("exists, but expired record", func() {
@@ -335,24 +367,56 @@ func (suite *redisTestSuite) TestGetUuid() {
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
suite.Require().False(found)
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Empty(resp, "should cleanup expired records")
})
suite.RunSubTest("exists, but corrupted record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
"corrupted value",
)
uuid, found, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().False(found)
suite.Require().Error(err, "Got unexpected response from the mojangUsernameToUuid hash: \"corrupted value\"")
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Empty(resp, "should cleanup expired records")
})
}
func (suite *redisTestSuite) TestStoreUuid() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
suite.RunSubTest("store uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().False(resp.IsType(redis.Nil))
str, _ := resp.Str()
suite.Require().Equal(str, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Equal(resp, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
})
suite.RunSubTest("store empty uuid", func() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().Equal(resp, ":1587435016")
})
}
func (suite *redisTestSuite) TestPing() {

View File

@@ -1,7 +1,7 @@
package di
import (
"github.com/goava/di"
"github.com/defval/di"
"github.com/spf13/viper"
)

View File

@@ -1,10 +1,11 @@
package di
import (
"context"
"fmt"
"path"
"github.com/goava/di"
"github.com/defval/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/db/fs"
@@ -22,7 +23,7 @@ import (
var db = di.Options(
di.Provide(newRedis,
di.As(new(http.SkinsRepository)),
di.As(new(mojangtextures.UuidsStorage)),
di.As(new(mojangtextures.UUIDsStorage)),
),
di.Provide(newFSFactory,
di.As(new(http.CapesRepository)),
@@ -33,9 +34,10 @@ var db = di.Options(
func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) {
config.SetDefault("storage.redis.host", "localhost")
config.SetDefault("storage.redis.port", 6379)
config.SetDefault("storage.redis.poll", 10)
config.SetDefault("storage.redis.poolSize", 10)
conn, err := redis.New(
context.Background(),
fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")),
config.GetInt("storage.redis.poolSize"),
)
@@ -66,8 +68,5 @@ func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) {
}
func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage {
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
return texturesStorage
return mojangtextures.NewInMemoryTexturesStorage()
}

View File

@@ -1,6 +1,6 @@
package di
import "github.com/goava/di"
import "github.com/defval/di"
func New() (*di.Container, error) {
container, err := di.New(
@@ -11,6 +11,7 @@ func New() (*di.Container, error) {
mojangTextures,
handlers,
server,
signer,
)
if err != nil {
return nil, err

View File

@@ -1,7 +1,7 @@
package di
import (
"github.com/goava/di"
"github.com/defval/di"
"github.com/mono83/slf"
d "github.com/elyby/chrly/dispatcher"
@@ -30,7 +30,7 @@ func enableEventsHandlers(
logger slf.Logger,
statsReporter slf.StatsReporter,
) {
// TODO: use idea from https://github.com/goava/di/issues/10#issuecomment-615869852
// TODO: use idea from https://github.com/defval/di/issues/10#issuecomment-615869852
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
(&eventsubscribers.StatsReporter{StatsReporter: statsReporter}).ConfigureWithDispatcher(dispatcher)
}

View File

@@ -1,11 +1,12 @@
package di
import (
"errors"
"net/http"
"strings"
"github.com/etherlabsio/healthcheck"
"github.com/goava/di"
"github.com/defval/di"
"github.com/etherlabsio/healthcheck/v2"
"github.com/gorilla/mux"
"github.com/spf13/viper"
@@ -74,10 +75,15 @@ func newHandlerFactory(
mount(router, "/api", apiRouter)
}
err := container.Invoke(enableReporters)
if err != nil && !errors.Is(err, di.ErrTypeNotExists) {
return nil, err
}
// Resolve health checkers last, because all the services required by the application
// must first be initialized and each of them can publish its own checkers
var healthCheckers []*namedHealthChecker
if container.Has(&healthCheckers) {
if has, _ := container.Has(&healthCheckers); has {
if err := container.Resolve(&healthCheckers); err != nil {
return nil, err
}
@@ -99,23 +105,29 @@ func newSkinsystemHandler(
skinsRepository SkinsRepository,
capesRepository CapesRepository,
mojangTexturesProvider MojangTexturesProvider,
) *mux.Router {
texturesSigner TexturesSigner,
) (*mux.Router, error) {
config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
return (&Skinsystem{
Emitter: emitter,
SkinsRepo: skinsRepository,
CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider,
TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
}).Handler()
app, err := NewSkinsystem(
emitter,
skinsRepository,
capesRepository,
mojangTexturesProvider,
texturesSigner,
config.GetString("textures.extra_param_name"),
config.GetString("textures.extra_param_value"),
)
if err != nil {
return nil, err
}
return app.Handler(), nil
}
func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router {
func newApiHandler(skinsRepository SkinsRepository) *mux.Router {
return (&Api{
Emitter: emitter,
SkinsRepo: skinsRepository,
}).Handler()
}

View File

@@ -3,8 +3,8 @@ package di
import (
"os"
"github.com/defval/di"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"github.com/mono83/slf"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
@@ -13,6 +13,7 @@ import (
"github.com/mono83/slf/wd"
"github.com/spf13/viper"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/version"
)
@@ -95,3 +96,9 @@ func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) {
return wd.Custom("", "", dispatcher), nil
}
func enableReporters(reporter slf.StatsReporter, factories []eventsubscribers.Reporter) {
for _, factory := range factories {
factory.Enable(reporter)
}
}

View File

@@ -1,28 +1,58 @@
package di
import (
"context"
"fmt"
"net/url"
"time"
"github.com/goava/di"
"github.com/defval/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/api/mojang"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var mojangTextures = di.Options(
di.Invoke(interceptMojangApiUrls),
di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangTexturesBatchUUIDsProviderStrategyFactory),
di.Provide(newMojangTexturesBatchUUIDsProviderDelayedStrategy),
di.Provide(newMojangTexturesBatchUUIDsProviderFullBusStrategy),
di.Provide(newMojangTexturesRemoteUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider),
di.Provide(newMojangTexturesStorageFactory),
)
func interceptMojangApiUrls(config *viper.Viper) error {
apiUrl := config.GetString("mojang.api_base_url")
if apiUrl != "" {
u, err := url.ParseRequestURI(apiUrl)
if err != nil {
return err
}
mojang.ApiMojangDotComAddr = u.String()
}
sessionServerUrl := config.GetString("mojang.session_server_base_url")
if sessionServerUrl != "" {
u, err := url.ParseRequestURI(apiUrl)
if err != nil {
return err
}
mojang.SessionServerMojangComAddr = u.String()
}
return nil
}
func newMojangTexturesProviderFactory(
container *di.Container,
config *viper.Viper,
@@ -75,7 +105,7 @@ func newMojangTexturesUuidsProviderFactory(
func newMojangTexturesBatchUUIDsProvider(
container *di.Container,
config *viper.Viper,
strategy mojangtextures.BatchUuidsProviderStrategy,
emitter mojangtextures.Emitter,
) (*mojangtextures.BatchUuidsProvider, error) {
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
@@ -106,17 +136,60 @@ func newMojangTexturesBatchUUIDsProvider(
return nil, err
}
return mojangtextures.NewBatchUuidsProvider(context.Background(), strategy, emitter), nil
}
func newMojangTexturesBatchUUIDsProviderStrategyFactory(
container *di.Container,
config *viper.Viper,
) (mojangtextures.BatchUuidsProviderStrategy, error) {
config.SetDefault("queue.strategy", "periodic")
strategyName := config.GetString("queue.strategy")
switch strategyName {
case "periodic":
var strategy *mojangtextures.PeriodicStrategy
err := container.Resolve(&strategy)
if err != nil {
return nil, err
}
return strategy, nil
case "full-bus":
var strategy *mojangtextures.FullBusStrategy
err := container.Resolve(&strategy)
if err != nil {
return nil, err
}
return strategy, nil
default:
return nil, fmt.Errorf("unknown queue strategy \"%s\"", strategyName)
}
}
func newMojangTexturesBatchUUIDsProviderDelayedStrategy(config *viper.Viper) *mojangtextures.PeriodicStrategy {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return &mojangtextures.BatchUuidsProvider{
Emitter: emitter,
IterationDelay: config.GetDuration("queue.loop_delay"),
IterationSize: config.GetInt("queue.batch_size"),
}, nil
return mojangtextures.NewPeriodicStrategy(
config.GetDuration("queue.loop_delay"),
config.GetInt("queue.batch_size"),
)
}
func newMojangTexturesBatchUUIDsProviderFullBusStrategy(config *viper.Viper) *mojangtextures.FullBusStrategy {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return mojangtextures.NewFullBusStrategy(
config.GetDuration("queue.loop_delay"),
config.GetInt("queue.batch_size"),
)
}
func newMojangTexturesRemoteUUIDsProvider(
container *di.Container,
config *viper.Viper,
emitter mojangtextures.Emitter,
) (*mojangtextures.RemoteApiUuidsProvider, error) {
@@ -125,6 +198,20 @@ func newMojangTexturesRemoteUUIDsProvider(
return nil, fmt.Errorf("unable to parse remote url: %w", err)
}
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_api_textures_provider_cool_down_duration", time.Minute+10*time.Second)
return &namedHealthChecker{
Name: "mojang-api-textures-provider-response-checker",
Checker: es.MojangApiTexturesProviderResponseChecker(
emitter,
config.GetDuration("healthcheck.mojang_api_textures_provider_cool_down_duration"),
),
}
}); err != nil {
return nil, err
}
return &mojangtextures.RemoteApiUuidsProvider{
Emitter: emitter,
Url: *remoteUrl,
@@ -138,11 +225,11 @@ func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextu
}
func newMojangTexturesStorageFactory(
uuidsStorage mojangtextures.UuidsStorage,
uuidsStorage mojangtextures.UUIDsStorage,
texturesStorage mojangtextures.TexturesStorage,
) mojangtextures.Storage {
return &mojangtextures.SeparatedStorage{
UuidsStorage: uuidsStorage,
UUIDsStorage: uuidsStorage,
TexturesStorage: texturesStorage,
}
}

View File

@@ -4,10 +4,11 @@ import (
"errors"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/defval/di"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"github.com/spf13/viper"
. "github.com/elyby/chrly/http"
@@ -42,13 +43,26 @@ func newServer(params serverParams) *http.Server {
params.Config.SetDefault("server.host", "")
params.Config.SetDefault("server.port", 80)
handler := params.Handler
var handler http.Handler
if params.Sentry != nil {
// raven.Recoverer uses DefaultClient and nothing can be done about it
// To avoid code duplication, if the Sentry service is successfully initiated,
// it will also replace DefaultClient, so raven.Recoverer will work with the instance
// created in the application constructor
handler = raven.Recoverer(handler)
handler = raven.Recoverer(params.Handler)
} else {
// Raven's Recoverer is prints the stacktrace and sets the corresponding status itself.
// But there is no magic and if you don't define a panic handler, Mux will just reset the connection
handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
debug.PrintStack() // TODO: colorize output
request.WriteHeader(http.StatusInternalServerError)
}
}()
params.Handler.ServeHTTP(request, response)
})
}
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))

48
di/signer.go Normal file
View File

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

View File

@@ -6,7 +6,7 @@ import (
"sync"
"time"
"github.com/etherlabsio/healthcheck"
"github.com/etherlabsio/healthcheck/v2"
"github.com/elyby/chrly/api/mojang"
)
@@ -32,33 +32,16 @@ func DatabaseChecker(connection Pingable) healthcheck.CheckerFunc {
}
func MojangBatchUuidsProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
var mutex sync.Mutex
var lastCallErr error
var expireTimer *time.Timer
errHolder := &expiringErrHolder{D: resetDuration}
dispatcher.Subscribe(
"mojang_textures:batch_uuids_provider:result",
func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
mutex.Lock()
defer mutex.Unlock()
lastCallErr = err
if expireTimer != nil {
expireTimer.Stop()
}
expireTimer = time.AfterFunc(resetDuration, func() {
mutex.Lock()
lastCallErr = nil
mutex.Unlock()
})
errHolder.Set(err)
},
)
return func(ctx context.Context) error {
mutex.Lock()
defer mutex.Unlock()
return lastCallErr
return errHolder.Get()
}
}
@@ -82,3 +65,47 @@ func MojangBatchUuidsProviderQueueLengthChecker(dispatcher Subscriber, maxLength
return errors.New("the maximum number of tasks in the queue has been exceeded")
}
}
func MojangApiTexturesProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
errHolder := &expiringErrHolder{D: resetDuration}
dispatcher.Subscribe(
"mojang_textures:mojang_api_textures_provider:after_request",
func(uuid string, profile *mojang.SignedTexturesResponse, err error) {
errHolder.Set(err)
},
)
return func(ctx context.Context) error {
return errHolder.Get()
}
}
type expiringErrHolder struct {
D time.Duration
err error
l sync.Mutex
t *time.Timer
}
func (h *expiringErrHolder) Get() error {
h.l.Lock()
defer h.l.Unlock()
return h.err
}
func (h *expiringErrHolder) Set(err error) {
h.l.Lock()
defer h.l.Unlock()
if h.t != nil {
h.t.Stop()
h.t = nil
}
h.err = err
if err != nil {
h.t = time.AfterFunc(h.D, func() {
h.Set(nil)
})
}
}

View File

@@ -43,7 +43,9 @@ func TestDatabaseChecker(t *testing.T) {
waitChan := make(chan time.Time, 1)
p.On("Ping").WaitUntil(waitChan).Return(nil)
ctx, _ := context.WithTimeout(context.Background(), 0)
ctx, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
checker := DatabaseChecker(p)
assert.Errorf(t, checker(ctx), "check timeout")
close(waitChan)
@@ -56,7 +58,7 @@ func TestMojangBatchUuidsProviderChecker(t *testing.T) {
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
//
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
@@ -107,3 +109,40 @@ func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
}
})
}
func TestMojangApiTexturesProviderResponseChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
d.Emit("mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
&mojang.SignedTexturesResponse{},
nil,
)
assert.Nil(t, checker(context.Background()))
})
t.Run("when error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("should reset value after passed duration", func(t *testing.T) {
d := dispatcher.New()
checker := MojangApiTexturesProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:mojang_api_textures_provider:after_request", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, err)
assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
}

View File

@@ -19,6 +19,17 @@ type StatsReporter struct {
timersMutex sync.Mutex
}
type Reporter interface {
Enable(reporter slf.StatsReporter)
}
type ReporterFunc func(reporter slf.StatsReporter)
func (f ReporterFunc) Enable(reporter slf.StatsReporter) {
f(reporter)
}
// TODO: rework all reporters in the same style as it was there: https://github.com/elyby/chrly/blob/1543e98b/di/db.go#L48-L52
func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
s.timersMap = make(map[string]time.Time)
@@ -34,15 +45,15 @@ func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
// Mojang signed textures source events
d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request"))
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, err error) {
if err != nil {
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, found bool, err error) {
if err != nil || !found {
return
}
if uuid == "" {
s.IncCounter("mojang_textures:usernames:cache_hit_nil", 1)
s.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
} else {
s.IncCounter("mojang_textures:usernames:cache_hit", 1)
s.IncCounter("mojang_textures.usernames.cache_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
@@ -96,12 +107,12 @@ func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) {
s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames)))
s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize))
if len(usernames) != 0 {
s.startTimeRecording("batch_uuids_provider_round_time_" + strings.Join(usernames, "|"))
}
})
d.Subscribe("mojang_textures:batch_uuids_provider:before_round", func() {
s.startTimeRecording("batch_uuids_provider_round_time")
})
d.Subscribe("mojang_textures:batch_uuids_provider:after_round", func() {
s.finalizeTimeRecording("batch_uuids_provider_round_time", "mojang_textures.usernames.round_time")
d.Subscribe("mojang_textures:batch_uuids_provider:result", func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
s.finalizeTimeRecording("batch_uuids_provider_round_time_"+strings.Join(usernames, "|"), "mojang_textures.usernames.round_time")
})
}
@@ -121,6 +132,8 @@ func (s *StatsReporter) handleBeforeRequest(req *http.Request) {
key = "signed_textures.request"
} else if strings.HasPrefix(p, "/textures/") {
key = "textures.request"
} else if strings.HasPrefix(p, "/profile/") {
key = "profiles.request"
} else if m == http.MethodPost && p == "/api/skins" {
key = "api.skins.post.request"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") {

View File

@@ -98,6 +98,14 @@ var statsReporterTestCases = []*StatsReporterTestCase{
{"IncCounter", "signed_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/profile/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "profiles.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil)},
@@ -213,24 +221,30 @@ var statsReporterTestCases = []*StatsReporterTestCase{
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", errors.New("error")},
{"mojang_textures:usernames:after_cache", "username", "", false, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", nil},
{"mojang_textures:usernames:after_cache", "username", "", false, nil},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", true, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit_nil", int64(1)},
{"IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil},
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit", int64(1)},
{"IncCounter", "mojang_textures.usernames.cache_hit", int64(1)},
},
},
{
@@ -337,19 +351,24 @@ var statsReporterTestCases = []*StatsReporterTestCase{
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 5},
{"mojang_textures:batch_uuids_provider:result", []string{"username1", "username2"}, []*mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:before_round"},
{"mojang_textures:batch_uuids_provider:after_round"},
{"mojang_textures:batch_uuids_provider:round", []string{}, 0},
// This event will be not emitted, but we emit it to ensure, that RecordTimer will not be called
{"mojang_textures:batch_uuids_provider:result", []string{}, []*mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(0)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)},
// Should not call RecordTimer
},
},
}

55
go.mod Normal file
View File

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

106
go.sum Normal file
View File

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

View File

@@ -13,20 +13,12 @@ import (
"github.com/elyby/chrly/model"
)
//noinspection GoSnakeCaseUsage
// noinspection GoSnakeCaseUsage
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
var regexUuidAny = regexp.MustCompile(UUID_ANY)
func init() {
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
if message == "" {
message = "Skin uploading is temporary unavailable"
}
return errors.New(message)
})
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
@@ -43,7 +35,6 @@ func init() {
}
type Api struct {
Emitter
SkinsRepo SkinsRepository
}
@@ -68,9 +59,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
record, err := ctx.findIdentityOrCleanup(identityId, username)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err))
apiServerError(resp)
return
panic(err)
}
if record == nil {
@@ -94,9 +83,7 @@ func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
err = ctx.SkinsRepo.SaveSkin(record)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
panic(err)
}
resp.WriteHeader(http.StatusCreated)
@@ -116,9 +103,7 @@ func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err))
apiServerError(resp)
return
panic(err)
}
if skin == nil {
@@ -128,9 +113,7 @@ func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
panic(err)
}
resp.WriteHeader(http.StatusNoContent)
@@ -172,50 +155,41 @@ func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.S
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
_ = request.ParseMultipartForm(maxMultipartMemory)
_ = request.ParseForm()
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"is1_8": {"bool"},
"isSlim": {"bool"},
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric"},
"url": {},
"is1_8": {"bool"},
"isSlim": {"bool"},
"mojangTextures": {},
"mojangSignature": {},
}
shouldAppendSkinRequiredError := false
url := request.Form.Get("url")
_, _, skinErr := request.FormFile("skin")
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
shouldAppendSkinRequiredError = true
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
if url == "" {
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:0,0")
} else {
validationRules["url"] = append(validationRules["url"], "url")
validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:1,")
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = []string{"required"}
validationRules["mojangSignature"] = append(validationRules["mojangSignature"], "required")
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
FormSize: maxMultipartMemory,
})
validationResults := validator.Validate()
if shouldAppendSkinRequiredError {
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
}
if len(validationResults) != 0 {
return validationResults

View File

@@ -6,7 +6,6 @@ import (
"errors"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
@@ -28,7 +27,6 @@ type apiTestSuite struct {
App *Api
SkinsRepository *skinsRepositoryMock
Emitter *emitterMock
}
/********************
@@ -37,17 +35,14 @@ type apiTestSuite struct {
func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.Emitter = &emitterMock{}
suite.App = &Api{
SkinsRepo: suite.SkinsRepository,
Emitter: suite.Emitter,
}
}
func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *apiTestSuite) RunSubTest(name string, subTest func()) {
@@ -72,6 +67,7 @@ type postSkinTestCase struct {
Name string
Form io.Reader
BeforeTest func(suite *apiTestSuite)
PanicErr string
AfterTest func(suite *apiTestSuite, response *http.Response)
}
@@ -139,6 +135,41 @@ var postSkinTestsCases = []*postSkinTestCase{
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing textures data to empty",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"0"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {""},
"mojangTextures": {""},
"mojangSignature": {""},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(0, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("", model.Url)
suite.Equal("", model.MojangTextures)
suite.Equal("", model.MojangSignature)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := io.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Update exists identity by changing its identityId",
Form: bytes.NewBufferString(url.Values{
@@ -198,6 +229,22 @@ var postSkinTestsCases = []*postSkinTestCase{
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, errors.New("can't find skin by user id"))
},
PanicErr: "can't find skin by user id",
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
@@ -209,43 +256,9 @@ var postSkinTestsCases = []*postSkinTestCase{
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "unable to save record to the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "error on requesting a skin from the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures"))
},
PanicErr: "can't save textures",
},
}
@@ -258,9 +271,14 @@ func (suite *apiTestSuite) TestPostSkin() {
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
@@ -287,7 +305,7 @@ func (suite *apiTestSuite) TestPostSkin() {
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
"The skinId field must be numeric value between 0 and 0"
],
"username": [
"The username field is required"
@@ -296,54 +314,12 @@ func (suite *apiTestSuite) TestPostSkin() {
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
}
}`, string(body))
})
suite.RunSubTest("Upload textures with skin as file", func() {
inputBody := &bytes.Buffer{}
writer := multipart.NewWriter(inputBody)
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://chrly/skins", inputBody)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
responseBody, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(responseBody))
})
}
/**************************************

View File

@@ -7,6 +7,7 @@ import (
"os"
"os/signal"
"strings"
"syscall"
"github.com/gorilla/mux"
"github.com/mono83/slf"
@@ -28,15 +29,14 @@ func StartServer(server *http.Server, logger slf.Logger) {
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
close(done)
}
close(done)
}()
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String()))
server.Shutdown(context.Background())
_ = server.Shutdown(context.Background())
logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String()))
close(done)
}()
@@ -46,7 +46,7 @@ func StartServer(server *http.Server, logger slf.Logger) {
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM, os.Kill)
return <-ch
}
@@ -135,7 +135,3 @@ func apiNotFound(resp http.ResponseWriter, reason string) {
})
_, _ = resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

View File

@@ -32,7 +32,7 @@ func TestJwtAuth_Authenticate(t *testing.T) {
emitter.On("Emit", "authentication:success")
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
req.Header.Add("Authorization", "Bearer "+jwt)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
@@ -99,7 +99,7 @@ func TestJwtAuth_Authenticate(t *testing.T) {
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
req.Header.Add("Authorization", "Bearer "+jwt)
jwt := &JwtAuth{Emitter: emitter}
err := jwt.Authenticate(req)
@@ -116,7 +116,7 @@ func TestJwtAuth_Authenticate(t *testing.T) {
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
req.Header.Add("Authorization", "Bearer "+jwt)
jwt := &JwtAuth{Key: []byte("this is another secret"), Emitter: emitter}
err := jwt.Authenticate(req)

View File

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

View File

@@ -2,11 +2,17 @@ package http
import (
"bytes"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"image"
"image/png"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -89,6 +95,25 @@ func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.Si
return result, args.Error(1)
}
type texturesSignerMock struct {
mock.Mock
}
func (m *texturesSignerMock) SignTextures(textures string) (string, error) {
args := m.Called(textures)
return args.String(0), args.Error(1)
}
func (m *texturesSignerMock) GetPublicKey() (*rsa.PublicKey, error) {
args := m.Called()
var publicKey *rsa.PublicKey
if casted, ok := args.Get(0).(*rsa.PublicKey); ok {
publicKey = casted
}
return publicKey, args.Error(1)
}
type skinsystemTestSuite struct {
suite.Suite
@@ -97,6 +122,7 @@ type skinsystemTestSuite struct {
SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock
TexturesSigner *texturesSignerMock
Emitter *emitterMock
}
@@ -105,25 +131,35 @@ type skinsystemTestSuite struct {
********************/
func (suite *skinsystemTestSuite) SetupTest() {
timeNow = func() time.Time {
CET, _ := time.LoadLocation("CET")
return time.Date(2021, 02, 25, 01, 50, 23, 0, CET)
}
suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.TexturesSigner = &texturesSignerMock{}
suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
Emitter: suite.Emitter,
TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue",
}
suite.TexturesSigner.On("SignTextures", "texturesParamValue").Times(1).Return("texturesParamSignature", nil)
suite.App, _ = NewSkinsystem(
suite.Emitter,
suite.SkinsRepository,
suite.CapesRepository,
suite.MojangTexturesProvider,
suite.TexturesSigner,
"texturesParamName",
"texturesParamValue",
)
}
func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.TexturesSigner.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
@@ -144,6 +180,7 @@ func TestSkinsystem(t *testing.T) {
type skinsystemTestCase struct {
Name string
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
@@ -156,6 +193,7 @@ var skinsTestsCases = []*skinsystemTestCase{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
@@ -166,7 +204,7 @@ var skinsTestsCases = []*skinsystemTestCase{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
@@ -174,10 +212,20 @@ var skinsTestsCases = []*skinsystemTestCase{
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no skin texture",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
@@ -193,6 +241,13 @@ var skinsTestsCases = []*skinsystemTestCase{
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestSkin() {
@@ -203,14 +258,20 @@ func (suite *skinsystemTestSuite) TestSkin() {
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder()
@@ -231,14 +292,18 @@ func (suite *skinsystemTestSuite) TestSkinGET() {
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
@@ -257,6 +322,7 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -269,8 +335,8 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, true), nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
@@ -278,10 +344,20 @@ var capesTestsCases = []*skinsystemTestCase{
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no cape texture",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
@@ -290,13 +366,20 @@ var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestCape() {
@@ -307,13 +390,19 @@ func (suite *skinsystemTestSuite) TestCape() {
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
@@ -337,14 +426,18 @@ func (suite *skinsystemTestSuite) TestCapeGET() {
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
@@ -397,23 +490,9 @@ var texturesTestsCases = []*skinsystemTestCase{
}`, string(body))
},
},
{
Name: "Username exists and has cape, no skin",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
// There is no case when the user has cape, but has no skin.
// In v5 we will rework textures repositories to be more generic about source of textures,
// but right now it's not possible to return profile entity with a cape only.
{
Name: "Username exists and has both skin and cape",
BeforeTest: func(suite *skinsystemTestSuite) {
@@ -438,8 +517,7 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists, but Mojang profile available",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(true, true), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@@ -456,11 +534,20 @@ var texturesTestsCases = []*skinsystemTestCase{
},
},
{
Name: "Username not exists, but Mojang profile available, but there is no textures",
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(false, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty properties",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
@@ -470,7 +557,6 @@ var texturesTestsCases = []*skinsystemTestCase{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
@@ -479,6 +565,13 @@ var texturesTestsCases = []*skinsystemTestCase{
suite.Equal("", string(body))
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestTextures() {
@@ -489,9 +582,14 @@ func (suite *skinsystemTestSuite) TestTextures() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
}
@@ -504,6 +602,7 @@ type signedTexturesTestCase struct {
Name string
AllowProxy bool
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
@@ -513,6 +612,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
@@ -555,6 +655,7 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
@@ -567,19 +668,20 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "00000000000000000000000000000000",
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ=="
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==",
"signature": "mojang signature"
},
{
"name": "texturesParamName",
@@ -602,6 +704,13 @@ var signedTexturesTestsCases = []*signedTexturesTestCase{
suite.Equal("", string(body))
},
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
}
func (suite *skinsystemTestSuite) TestSignedTextures() {
@@ -619,9 +728,480 @@ func (suite *skinsystemTestSuite) TestSignedTextures() {
req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
}
testCase.AfterTest(suite, w.Result())
/***************************
* Get profile tests cases *
***************************/
type profileTestCase struct {
Name string
Signed bool
ForceResponse string
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var profileTestsCases = []*profileTestCase{
{
Name: "Username exists and has both skin and cape, don't sign",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has both skin and cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
suite.TexturesSigner.On("SignTextures", "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19").Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19"
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has skin, no cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifX19"
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists and has slim skin, no cape",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "textures signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmciLCJtZXRhZGF0YSI6eyJtb2RlbCI6InNsaW0ifX19fQ=="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists, but has no skin and Mojang profile with textures available",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
skin := createSkinModel("mock_username", false)
skin.SkinId = 0
skin.Url = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username exists, but has no skin and Mojang textures proxy returned an error",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
skin := createSkinModel("mock_username", false)
skin.SkinId = 0
skin.Url = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("shit happened"))
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile with textures available",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is an empty properties",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "292a1db7353d476ca99cab8f57mojang",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username not exists and Mojang profile unavailable, but there is a forceResponse param",
ForceResponse: "a12e41a4-e8e5-4503-987e-0adacf72ab93",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "a12e41a4e8e54503987e0adacf72ab93",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "chrly signature",
"value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6ImExMmU0MWE0ZThlNTQ1MDM5ODdlMGFkYWNmNzJhYjkzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ=="
},
{
"name": "texturesParamName",
"signature": "texturesParamSignature",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists and Mojang textures proxy returned an error",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("mojang textures provider error"))
},
PanicErr: "mojang textures provider error",
},
{
Name: "Receive an error from the SkinsRepository",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error"))
},
PanicErr: "skins repository error",
},
{
Name: "Receive an error from the TexturesSigner",
Signed: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.TexturesSigner.On("SignTextures", mock.Anything).Return("", errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (suite *skinsystemTestSuite) TestProfile() {
for _, testCase := range profileTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
u, _ := url.Parse("http://chrly/profile/mock_username")
q := make(url.Values)
if testCase.Signed {
q.Set("unsigned", "false")
}
if testCase.ForceResponse != "" {
q.Set("onUnknownProfileRespondWithUuid", testCase.ForceResponse)
}
u.RawQuery = q.Encode()
req := httptest.NewRequest("GET", u.String(), nil)
w := httptest.NewRecorder()
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
}
/***************************
* Get profile tests cases *
***************************/
type signingKeyTestCase struct {
Name string
KeyFormat string
BeforeTest func(suite *skinsystemTestSuite)
PanicErr string
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var signingKeyTestsCases = []*signingKeyTestCase{
{
Name: "Get public key in DER format",
KeyFormat: "DER",
BeforeTest: func(suite *skinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/octet-stream", response.Header.Get("Content-Type"))
suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.der\"", response.Header.Get("Content-Disposition"))
body, _ := ioutil.ReadAll(response.Body)
suite.Equal([]byte{48, 92, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 3, 75, 0, 48, 72, 2, 65, 0, 214, 212, 165, 80, 153, 144, 194, 169, 126, 246, 25, 211, 197, 183, 150, 233, 157, 1, 166, 49, 44, 25, 230, 80, 57, 115, 28, 20, 7, 220, 58, 88, 121, 254, 86, 8, 237, 246, 76, 53, 58, 125, 226, 9, 231, 192, 52, 148, 12, 176, 130, 214, 120, 195, 8, 182, 116, 97, 206, 207, 253, 97, 2, 247, 2, 3, 1, 0, 1}, body)
},
},
{
Name: "Get public key in PEM format",
KeyFormat: "PEM",
BeforeTest: func(suite *skinsystemTestSuite) {
pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----"))
publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes)
suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("text/plain; charset=utf-8", response.Header.Get("Content-Type"))
suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.pem\"", response.Header.Get("Content-Disposition"))
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----\n", string(body))
},
},
{
Name: "Error while obtaining public key",
KeyFormat: "DER",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.TexturesSigner.On("GetPublicKey").Return(nil, errors.New("textures signer error"))
},
PanicErr: "textures signer error",
},
}
func (suite *skinsystemTestSuite) TestSignatureVerificationKey() {
for _, testCase := range signingKeyTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/signature-verification-key."+strings.ToLower(testCase.KeyFormat), nil)
w := httptest.NewRecorder()
if testCase.PanicErr != "" {
suite.PanicsWithError(testCase.PanicErr, func() {
suite.App.Handler().ServeHTTP(w, req)
})
} else {
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
}
})
}
}
@@ -666,11 +1246,19 @@ func createCapeModel() *model.Cape {
return &model.Cape{File: bytes.NewReader(createCape())}
}
func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
func createEmptyMojangResponse() *mojang.SignedTexturesResponse {
return &mojang.SignedTexturesResponse{
Id: "292a1db7353d476ca99cab8f57mojang",
Name: "mock_username",
Props: []*mojang.Property{},
}
}
func createMojangResponseWithTextures(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(),
ProfileID: "00000000000000000000000000000000",
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).UnixNano() / int64(time.Millisecond),
ProfileID: "292a1db7353d476ca99cab8f57mojang",
ProfileName: "mock_username",
Textures: &mojang.TexturesResponse{},
}
@@ -687,16 +1275,12 @@ func createMojangResponse(includeSkin bool, includeCape bool) *mojang.SignedText
}
}
response := &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock_username",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
},
},
}
response := createEmptyMojangResponse()
response.Props = append(response.Props, &mojang.Property{
Name: "textures",
Value: mojang.EncodeTextures(textures),
Signature: "mojang signature",
})
return response
}

View File

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

View File

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

View File

@@ -1,64 +1,51 @@
package mojangtextures
import (
"crypto/rand"
"encoding/base64"
"strings"
"context"
"fmt"
"strconv"
"sync"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestJobsQueue(t *testing.T) {
createQueue := func() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}
t.Run("Enqueue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
assert.Equal(3, s.Size())
s := newJobsQueue()
require.Equal(t, 1, s.Enqueue(&job{Username: "username1"}))
require.Equal(t, 2, s.Enqueue(&job{Username: "username2"}))
require.Equal(t, 3, s.Enqueue(&job{Username: "username3"}))
})
t.Run("Dequeue", func(t *testing.T) {
assert := testify.New(t)
s := newJobsQueue()
s.Enqueue(&job{Username: "username1"})
s.Enqueue(&job{Username: "username2"})
s.Enqueue(&job{Username: "username3"})
s.Enqueue(&job{Username: "username4"})
s.Enqueue(&job{Username: "username5"})
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
s.Enqueue(&jobItem{username: "username4"})
items, queueLen := s.Dequeue(2)
require.Len(t, items, 2)
require.Equal(t, 3, queueLen)
require.Equal(t, "username1", items[0].Username)
require.Equal(t, "username2", items[1].Username)
items := s.Dequeue(2)
assert.Len(items, 2)
assert.Equal("username1", items[0].username)
assert.Equal("username2", items[1].username)
assert.Equal(2, s.Size())
items = s.Dequeue(40)
assert.Len(items, 2)
assert.Equal("username3", items[0].username)
assert.Equal("username4", items[1].username)
items, queueLen = s.Dequeue(40)
require.Len(t, items, 3)
require.Equal(t, 0, queueLen)
require.Equal(t, "username3", items[0].Username)
require.Equal(t, "username4", items[1].Username)
require.Equal(t, "username5", items[2].Username)
})
}
// This is really stupid test just to get 100% coverage on this package :)
func TestBatchUuidsProvider_forever(t *testing.T) {
testify.True(t, forever())
}
type mojangUsernamesToUuidsRequestMock struct {
mock.Mock
}
@@ -73,6 +60,37 @@ func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(usernames []string)
return result, args.Error(1)
}
type manualStrategy struct {
ch chan *JobsIteration
once sync.Once
lock sync.Mutex
jobs []*job
}
func (m *manualStrategy) Queue(job *job) {
m.lock.Lock()
m.jobs = append(m.jobs, job)
m.lock.Unlock()
}
func (m *manualStrategy) GetJobs(_ context.Context) <-chan *JobsIteration {
m.lock.Lock()
defer m.lock.Unlock()
m.ch = make(chan *JobsIteration)
return m.ch
}
func (m *manualStrategy) Iterate(countJobsToReturn int, countLeftJobsInQueue int) {
m.lock.Lock()
defer m.lock.Unlock()
m.ch <- &JobsIteration{
Jobs: m.jobs[0:countJobsToReturn],
Queue: countLeftJobsInQueue,
}
}
type batchUuidsProviderGetUuidResult struct {
Result *mojang.ProfileInfo
Error error
@@ -81,71 +99,54 @@ type batchUuidsProviderGetUuidResult struct {
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
GetUuidAsync func(username string) chan *batchUuidsProviderGetUuidResult
Provider *BatchUuidsProvider
Emitter *mockEmitter
Strategy *manualStrategy
MojangApi *mojangUsernamesToUuidsRequestMock
Iterate func()
done func()
iterateChan chan bool
stop context.CancelFunc
}
func (suite *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
s := make(chan struct{})
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before
// all usernames will be queued.
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
close(s)
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Provider = &BatchUuidsProvider{
Emitter: suite.Emitter,
IterationDelay: 0,
IterationSize: 10,
}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.GetUuidAsync = func(username string) chan *batchUuidsProviderGetUuidResult {
s := make(chan bool)
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before all usernames
// will be queued.
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
s <- true
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
suite.Strategy = &manualStrategy{}
ctx, stop := context.WithCancel(context.Background())
suite.stop = stop
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
usernamesToUuids = suite.MojangApi.UsernamesToUuids
suite.Provider = NewBatchUuidsProvider(ctx, suite.Strategy, suite.Emitter)
}
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.done()
suite.stop()
suite.Emitter.AssertExpectations(suite.T())
suite.MojangApi.AssertExpectations(suite.T())
}
@@ -154,37 +155,14 @@ func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForOneUsername() {
expectedUsernames := []string{"username"}
expectedResult := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResponse := []*mojang.ProfileInfo{expectedResult}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{expectedResult}, nil)
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result.Result)
suite.Assert().Nil(result.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernames() {
expectedUsernames := []string{"username1", "username2"}
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
expectedResponse := []*mojang.ProfileInfo{expectedResult1, expectedResult2}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
@@ -194,7 +172,7 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
suite.Strategy.Iterate(2, 0)
result1 := <-resultChan1
suite.Assert().Equal(expectedResult1, result1.Result)
@@ -205,78 +183,41 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
suite.Assert().Nil(result2.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
func (suite *batchUuidsProviderTestSuite) TestShouldNotSendRequestWhenNoJobsAreReturned() {
//noinspection GoPreferNilSlice
emptyUsernames := []string{}
done := make(chan struct{})
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:round",
emptyUsernames,
1,
).Once().Run(func(args mock.Arguments) {
close(done)
})
// In this test we're not testing response, so always return an empty resultset
expectedResponse := []*mojang.ProfileInfo{}
suite.GetUuidAsync("username") // Schedule one username to run the queue
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[0:10], 2).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[0:10], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[10:12], 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[10:12], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Twice()
suite.Strategy.Iterate(0, 1) // Return no jobs and indicate that there is one job in queue
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return(expectedResponse, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return(expectedResponse, nil)
channels := make([]chan *batchUuidsProviderGetUuidResult, len(usernames))
for i, username := range usernames {
channels[i] = suite.GetUuidAsync(username)
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
<-done
}
func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Times(3)
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", []string{"username"}, mock.Anything, nil).Once()
var nilStringSlice []string
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", nilStringSlice, 0).Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Times(3)
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finishes
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Nil(result.Result)
suite.Assert().Nil(result.Error)
// Let it to perform a few more iterations to ensure, that there are no calls to external APIs
suite.Iterate()
suite.Iterate()
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError() {
// Test written for multiple usernames to ensure that the error
// will be returned for each iteration group
func (suite *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
expectedUsernames := []string{"username1", "username2"}
expectedError := &mojang.TooManyRequestsError{}
var nilProfilesResponse []*mojang.ProfileInfo
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, nilProfilesResponse, expectedError).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
suite.Strategy.Iterate(2, 0)
result1 := <-resultChan1
suite.Assert().Nil(result1.Result)
@@ -287,14 +228,214 @@ func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError(
suite.Assert().Equal(expectedError, result2.Error)
}
var replacer = strings.NewReplacer("-", "_", "=", "")
func TestPeriodicStrategy(t *testing.T) {
t.Run("should return first job only after duration", func(t *testing.T) {
d := 20 * time.Millisecond
strategy := NewPeriodicStrategy(d, 10)
j := &job{}
strategy.Queue(j)
// https://stackoverflow.com/a/50581165
func randStr(len int) string {
buff := make([]byte, len)
_, _ = rand.Read(buff)
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
ctx, cancel := context.WithCancel(context.Background())
startedAt := time.Now()
ch := strategy.GetJobs(ctx)
iteration := <-ch
durationBeforeResult := time.Now().Sub(startedAt)
require.True(t, durationBeforeResult >= d)
require.True(t, durationBeforeResult < d*2)
// Base 64 can be longer than len
return str[:len]
require.Equal(t, []*job{j}, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
cancel()
})
t.Run("should return the configured batch size", func(t *testing.T) {
strategy := NewPeriodicStrategy(0, 10)
jobs := make([]*job, 15)
for i := 0; i < 15; i++ {
jobs[i] = &job{Username: strconv.Itoa(i)}
strategy.Queue(jobs[i])
}
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
iteration := <-ch
require.Len(t, iteration.Jobs, 10)
require.Equal(t, jobs[0:10], iteration.Jobs)
require.Equal(t, 5, iteration.Queue)
cancel()
})
t.Run("should not return the next iteration until the previous one is finished", func(t *testing.T) {
strategy := NewPeriodicStrategy(0, 10)
strategy.Queue(&job{})
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
iteration := <-ch
require.Len(t, iteration.Jobs, 1)
require.Equal(t, 0, iteration.Queue)
time.Sleep(time.Millisecond) // Let strategy's internal loop to work (if the implementation is broken)
select {
case <-ch:
require.Fail(t, "the previous iteration isn't marked as done")
default:
// ok
}
iteration.Done()
time.Sleep(time.Millisecond) // Let strategy's internal loop to work
select {
case iteration = <-ch:
// ok
default:
require.Fail(t, "iteration should be provided")
}
require.Empty(t, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
iteration.Done()
cancel()
})
t.Run("each iteration should be returned only after the configured duration", func(t *testing.T) {
d := 5 * time.Millisecond
strategy := NewPeriodicStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
for i := 0; i < 3; i++ {
startedAt := time.Now()
iteration := <-ch
durationBeforeResult := time.Now().Sub(startedAt)
require.True(t, durationBeforeResult >= d)
require.True(t, durationBeforeResult < d*2)
require.Empty(t, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
// Sleep for at least doubled duration before calling Done() to check,
// that this duration isn't included into the next iteration time
time.Sleep(d * 2)
iteration.Done()
}
cancel()
})
}
func TestFullBusStrategy(t *testing.T) {
t.Run("should provide iteration immediately when the batch size exceeded", func(t *testing.T) {
jobs := make([]*job, 10)
for i := 0; i < 10; i++ {
jobs[i] = &job{}
}
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
select {
case iteration := <-ch:
require.Len(t, iteration.Jobs, 10)
require.Equal(t, 0, iteration.Queue)
case <-time.After(d):
require.Fail(t, "iteration should be provided immediately")
}
}()
for _, j := range jobs {
strategy.Queue(j)
}
<-done
cancel()
})
t.Run("should provide iteration after duration if batch size isn't exceeded", func(t *testing.T) {
jobs := make([]*job, 9)
for i := 0; i < 9; i++ {
jobs[i] = &job{}
}
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
startedAt := time.Now()
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
iteration := <-ch
duration := time.Now().Sub(startedAt)
require.True(t, duration >= d, fmt.Sprintf("has %d, expected %d", duration, d))
require.True(t, duration < d*2)
require.Equal(t, jobs, iteration.Jobs)
require.Equal(t, 0, iteration.Queue)
}()
for _, j := range jobs {
strategy.Queue(j)
}
<-done
cancel()
})
t.Run("should provide iteration as soon as the bus is full, without waiting for the previous iteration to finish", func(t *testing.T) {
d := 20 * time.Millisecond
strategy := NewFullBusStrategy(d, 10)
ctx, cancel := context.WithCancel(context.Background())
ch := strategy.GetJobs(ctx)
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 3; i++ {
time.Sleep(5 * time.Millisecond) // See comment below
select {
case iteration := <-ch:
require.Len(t, iteration.Jobs, 10)
// Don't assert iteration.Queue length since it might be unstable
// Don't call iteration.Done()
case <-time.After(d):
t.Errorf("iteration should be provided as soon as the bus is full")
return
}
}
// Scheduled 31 tasks. 3 iterations should be performed immediately
// and should be executed only after timeout. The timeout above is used
// to increase overall time to ensure, that timer resets on every iteration
startedAt := time.Now()
iteration := <-ch
duration := time.Now().Sub(startedAt)
require.True(t, duration >= d)
require.True(t, duration < d*2)
require.Len(t, iteration.Jobs, 1)
require.Equal(t, 0, iteration.Queue)
}()
for i := 0; i < 31; i++ {
strategy.Queue(&job{})
}
<-done
cancel()
})
}

View File

@@ -5,12 +5,9 @@ import (
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/tevino/abool"
"github.com/elyby/chrly/utils"
)
var now = time.Now
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
@@ -20,9 +17,10 @@ type InMemoryTexturesStorage struct {
GCPeriod time.Duration
Duration time.Duration
lock sync.RWMutex
data map[string]*inMemoryItem
working *abool.AtomicBool
once sync.Once
lock sync.RWMutex
data map[string]*inMemoryItem
done chan struct{}
}
func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
@@ -35,30 +33,6 @@ func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
return storage
}
func (s *InMemoryTexturesStorage) Start() {
if s.working == nil {
s.working = abool.New()
}
if !s.working.IsSet() {
go func() {
time.Sleep(s.GCPeriod)
// TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right
for s.working.IsSet() {
start := time.Now()
s.gc()
time.Sleep(s.GCPeriod - time.Since(start))
}
}()
}
s.working.Set()
}
func (s *InMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.RLock()
defer s.lock.RUnlock()
@@ -66,34 +40,43 @@ func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTextur
item, exists := s.data[uuid]
validRange := s.getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, &ValueNotFound{}
return nil, nil
}
return item.textures, nil
}
func (s *InMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
var timestamp int64
if textures != nil {
decoded := textures.DecodeTextures()
if decoded == nil {
panic("unable to decode textures")
}
timestamp = decoded.Timestamp
} else {
timestamp = unixNanoToUnixMicro(now().UnixNano())
}
s.once.Do(s.start)
s.lock.Lock()
defer s.lock.Unlock()
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: timestamp,
timestamp: utils.UnixMillisecond(time.Now()),
}
}
func (s *InMemoryTexturesStorage) start() {
s.done = make(chan struct{})
ticker := time.NewTicker(s.GCPeriod)
go func() {
for {
select {
case <-s.done:
return
case <-ticker.C:
s.gc()
}
}
}()
}
func (s *InMemoryTexturesStorage) Stop() {
close(s.done)
}
func (s *InMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
@@ -107,9 +90,5 @@ func (s *InMemoryTexturesStorage) gc() {
}
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(s.Duration * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
return utils.UnixMillisecond(time.Now().Add(s.Duration * time.Duration(-1)))
}

View File

@@ -1,12 +1,12 @@
package mojangtextures
import (
"testing"
"time"
"github.com/elyby/chrly/api/mojang"
assert "github.com/stretchr/testify/require"
testify "github.com/stretchr/testify/assert"
"testing"
"github.com/elyby/chrly/api/mojang"
)
var texturesWithSkin = &mojang.SignedTexturesResponse{
@@ -45,156 +45,120 @@ var texturesWithoutSkin = &mojang.SignedTexturesResponse{
}
func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get error when uuid is not exists", func(t *testing.T) {
assert := testify.New(t)
t.Run("should return nil, nil when textures are unavailable", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
assert.Nil(t, result)
assert.Nil(t, err)
})
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) {
assert := testify.New(t)
t.Run("should return nil, nil when textures are exists, but cache duration is expired", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.Duration = 10 * time.Millisecond
storage.GCPeriod = time.Minute
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
now = func() time.Time {
return time.Now().Add(time.Minute * 2)
}
time.Sleep(storage.Duration * 2)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
now = time.Now
assert.Nil(t, result)
assert.Nil(t, err)
})
}
func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("override already existed textures for uuid", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(texturesWithoutSkin, result)
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
assert.NotEqual(t, texturesWithoutSkin, result)
assert.Equal(t, texturesWithSkin, result)
assert.Nil(t, err)
})
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Nil(err)
})
t.Run("should panic if textures prop is not decoded", func(t *testing.T) {
assert := testify.New(t)
toStore := &mojang.SignedTexturesResponse{
Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
t.Run("store textures with empty properties", func(t *testing.T) {
texturesWithEmptyProps := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{},
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithEmptyProps)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Exactly(t, texturesWithEmptyProps, result)
assert.Nil(t, err)
})
t.Run("store nil textures", func(t *testing.T) {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(t, result)
assert.Nil(t, err)
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
defer storage.Stop()
storage.GCPeriod = 10 * time.Millisecond
storage.Duration = 10 * time.Millisecond
storage.Duration = 9 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
}),
},
},
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
}),
},
},
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{},
}
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
// Store another texture a bit later to avoid it removing by GC after the first iteration
time.Sleep(2 * time.Millisecond)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
storage.lock.RLock()
assert.Len(t, storage.data, 2, "the GC period has not yet reached")
storage.lock.RUnlock()
time.Sleep(storage.GCPeriod + time.Millisecond) // Let it start first iteration
time.Sleep(storage.GCPeriod) // Let it perform the first GC iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
storage.lock.RLock()
assert.Len(t, storage.data, 1, "the first texture should be cleaned by GC")
assert.Contains(t, storage.data, "b5d58475007d4f9e9ddd1403e2497579")
storage.lock.RUnlock()
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(storage.GCPeriod) // Let another iteration happen
time.Sleep(storage.GCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Error(textures1Err)
assert.Error(textures2Err)
storage.Stop()
storage.lock.RLock()
assert.Len(t, storage.data, 0)
storage.lock.RUnlock()
}

View File

@@ -13,7 +13,7 @@ type MojangApiTexturesProvider struct {
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid)
result, err := uuidToTextures(uuid, true)
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", result, err)
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", uuid, result, err)
return result, err
}

View File

@@ -64,6 +64,7 @@ func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedResult,
nil,
).Once()
@@ -85,6 +86,7 @@ func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
expectedResponse,
expectedError,
).Once()

View File

@@ -1,7 +1,6 @@
package mojangtextures
import (
"errors"
"regexp"
"strings"
"sync"
@@ -61,8 +60,8 @@ func (c *broadcaster) BroadcastAndRemove(username string, result *broadcastResul
delete(c.listeners, username)
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
// https://help.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV
var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{3,16}$`)
type UUIDsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
@@ -92,20 +91,24 @@ func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResp
})
if !allowedUsernamesRegex.MatchString(username) {
return nil, errors.New("invalid username")
return nil, nil
}
username = strings.ToLower(username)
ctx.Emit("mojang_textures:call", username)
uuid, err := ctx.getUuidFromCache(username)
if err == nil && uuid == "" {
uuid, found, err := ctx.getUuidFromCache(username)
if err != nil {
return nil, err
}
if found && uuid == "" {
return nil, nil
}
if uuid != "" {
textures, err := ctx.getTexturesFromCache(uuid)
if err == nil {
if err == nil && textures != nil {
return textures, nil
}
}
@@ -131,7 +134,8 @@ func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
ctx.broadcaster.BroadcastAndRemove(username, result)
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
func (ctx *Provider) getResult(username string, cachedUuid string) *broadcastResult {
uuid := cachedUuid
if uuid == "" {
profile, err := ctx.getUuid(username)
if err != nil {
@@ -152,6 +156,12 @@ func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
textures, err := ctx.getTextures(uuid)
if err != nil {
// Previously cached UUIDs may disappear
// In this case we must invalidate UUID cache for given username
if _, ok := err.(*mojang.EmptyResponse); ok && cachedUuid != "" {
return ctx.getResult(username, "")
}
return &broadcastResult{nil, err}
}
@@ -162,12 +172,12 @@ func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
return &broadcastResult{textures, nil}
}
func (ctx *Provider) getUuidFromCache(username string) (string, error) {
func (ctx *Provider) getUuidFromCache(username string) (string, bool, error) {
ctx.Emit("mojang_textures:usernames:before_cache", username)
uuid, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, err)
uuid, found, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, found, err)
return uuid, err
return uuid, found, err
}
func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) {

View File

@@ -122,9 +122,9 @@ type mockStorage struct {
mock.Mock
}
func (m *mockStorage) GetUuid(username string) (string, error) {
func (m *mockStorage) GetUuid(username string) (string, bool, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *mockStorage) StoreUuid(username string, uuid string) error {
@@ -186,7 +186,7 @@ func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
@@ -194,7 +194,7 @@ func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
@@ -213,16 +213,16 @@ func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(expectedResult, nil)
@@ -238,11 +238,11 @@ func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
@@ -254,9 +254,9 @@ func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", true, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", nil)
suite.Storage.On("GetUuid", "username").Once().Return("", true, nil)
result, err := suite.Provider.GetForUsername("username")
@@ -270,13 +270,13 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "").Once().Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
@@ -293,7 +293,7 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
@@ -301,7 +301,7 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
@@ -314,13 +314,49 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM
suite.Assert().Nil(err)
}
// https://github.com/elyby/chrly/issues/29
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuidThatHasBeenDisappeared() {
expectedErr := &mojang.EmptyResponse{}
expectedProfile := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
var nilTexturesResponse *mojang.SignedTexturesResponse
expectedResult := &mojang.SignedTexturesResponse{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nilTexturesResponse, expectedErr).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true, nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
suite.Storage.On("StoreUuid", "username", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Once().Return(nil)
suite.Storage.On("StoreTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, expectedErr)
suite.TexturesProvider.On("GetTextures", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Twice()
suite.Emitter.On("Emit", "mojang_textures:already_processing", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
@@ -329,7 +365,7 @@ func (suite *providerTestSuite) TestGetForTheSameUsernames() {
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Twice().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Twice().Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
@@ -355,10 +391,25 @@ func (suite *providerTestSuite) TestGetForTheSameUsernames() {
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
result, err := suite.Provider.GetForUsername("Not allowed")
suite.Assert().Error(err, "invalid username")
suite.Assert().Nil(err)
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetErrorFromUUIDsStorage() {
expectedErr := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, expectedErr).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", false, expectedErr)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedErr, err)
}
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
@@ -366,13 +417,13 @@ func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Once().Return("", false, nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
result, resErr := suite.Provider.GetForUsername("username")
@@ -387,7 +438,7 @@ func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", false, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
@@ -395,7 +446,7 @@ func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "username").Return("", false, nil)
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)

View File

@@ -19,7 +19,7 @@ var HttpClient = &http.Client{
type RemoteApiUuidsProvider struct {
Emitter
Url URL
Url URL
}
func (ctx *RemoteApiUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {

View File

@@ -7,6 +7,7 @@ import (
"testing"
"github.com/h2non/gock"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)

View File

@@ -4,12 +4,13 @@ import (
"github.com/elyby/chrly/api/mojang"
)
// UuidsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// UUIDsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// used to reduce the load on the account information queue
type UuidsStorage interface {
// Since only primitive types are used in this method, you should return a special error ValueNotFound
// to return the information that no error has occurred and username does not have uuid
GetUuid(username string) (string, error)
type UUIDsStorage interface {
// The second argument indicates whether a record was found in the storage,
// since depending on it, the empty value must be interpreted as "no cached record"
// or "value cached and has an empty value"
GetUuid(username string) (uuid string, found bool, err error)
// An empty uuid value can be passed if the corresponding account has not been found
StoreUuid(username string, uuid string) error
}
@@ -24,23 +25,23 @@ type TexturesStorage interface {
}
type Storage interface {
UuidsStorage
UUIDsStorage
TexturesStorage
}
// SeparatedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SeparatedStorage struct {
UuidsStorage
UUIDsStorage
TexturesStorage
}
func (s *SeparatedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
func (s *SeparatedStorage) GetUuid(username string) (string, bool, error) {
return s.UUIDsStorage.GetUuid(username)
}
func (s *SeparatedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
return s.UUIDsStorage.StoreUuid(username, uuid)
}
func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
@@ -50,12 +51,3 @@ func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesRespo
func (s *SeparatedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -12,9 +12,9 @@ type uuidsStorageMock struct {
mock.Mock
}
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
func (m *uuidsStorageMock) GetUuid(username string) (string, bool, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error {
@@ -50,9 +50,10 @@ func TestSplittedStorage(t *testing.T) {
t.Run("GetUuid", func(t *testing.T) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("GetUuid", "username").Once().Return("find me", nil)
result, err := storage.GetUuid("username")
uuidsMock.On("GetUuid", "username").Once().Return("find me", true, nil)
result, found, err := storage.GetUuid("username")
assert.Nil(t, err)
assert.True(t, found)
assert.Equal(t, "find me", result)
uuidsMock.AssertExpectations(t)
})
@@ -82,8 +83,3 @@ func TestSplittedStorage(t *testing.T) {
texturesMock.AssertExpectations(t)
})
}
func TestValueNotFound_Error(t *testing.T) {
err := &ValueNotFound{}
assert.Equal(t, "value not found in the storage", err.Error())
}

42
signer/signer.go Normal file
View File

@@ -0,0 +1,42 @@
package signer
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"encoding/base64"
"errors"
)
var randomReader = rand.Reader
type Signer struct {
Key *rsa.PrivateKey
}
func (s *Signer) SignTextures(textures string) (string, error) {
if s.Key == nil {
return "", errors.New("Key is empty")
}
message := []byte(textures)
messageHash := sha1.New()
_, _ = messageHash.Write(message)
messageHashSum := messageHash.Sum(nil)
signature, err := rsa.SignPKCS1v15(randomReader, s.Key, crypto.SHA1, messageHashSum)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
func (s *Signer) GetPublicKey() (*rsa.PublicKey, error) {
if s.Key == nil {
return nil, errors.New("Key is empty")
}
return &s.Key.PublicKey, nil
}

64
signer/signer_test.go Normal file
View File

@@ -0,0 +1,64 @@
package signer
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"testing"
assert "github.com/stretchr/testify/require"
)
type ConstantReader struct {
}
func (c *ConstantReader) Read(p []byte) (int, error) {
return 1, nil
}
func TestSigner_SignTextures(t *testing.T) {
randomReader = &ConstantReader{}
t.Run("sign textures", func(t *testing.T) {
rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n"))
key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes)
signer := &Signer{key}
signature, err := signer.SignTextures("eyJ0aW1lc3RhbXAiOjE2MTQzMDcxMzQsInByb2ZpbGVJZCI6ImZmYzhmZGM5NTgyNDUwOWU4YTU3Yzk5Yjk0MGZiOTk2IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9lbHkuYnkvc3RvcmFnZS9za2lucy82OWM2NzQwZDI5OTNlNWQ2ZjZhN2ZjOTI0MjBlZmMyOS5wbmcifX0sImVseSI6dHJ1ZX0")
assert.NoError(t, err)
assert.Equal(t, "IyHCxTP5ITquEXTHcwCtLd08jWWy16JwlQeWg8naxhoAVQecHGRdzHRscuxtdq/446kmeox7h4EfRN2A2ZLL+A==", signature)
})
t.Run("empty key", func(t *testing.T) {
signer := &Signer{}
signature, err := signer.SignTextures("hello world")
assert.Error(t, err, "Key is empty")
assert.Empty(t, signature)
})
}
func TestSigner_GetPublicKey(t *testing.T) {
randomReader = &ConstantReader{}
t.Run("get public key", func(t *testing.T) {
rawKey, _ := pem.Decode([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOwIBAAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnmUDlzHBQH3DpYef5WCO32\nTDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQJAItaxSHTe6PKbyEU/9pxj\nONdhYRYwVLLo56gnMYhkyoEqaaMsfov8hhoepkYZBMvZFB2bDOsQ2SaJ+E2eiBO4\nAQIhAPssS0+BR9w0bOdmjGqmdE9NrN5UJQcOW13s29+6QzUBAiEA2vWOepA5Apiu\npEA3pwoGdkVCrNSnnKjDQzDXBnpd3/cCIEFNd9sY4qUG4FWdXN6RnmXL7Sj0uZfH\nDMwzu8rEM5sBAiEAhvdoDNqLmbMdq3c+FsPSOeL1d21Zp/JK8kbPtFmHNf8CIQDV\n6FSZDwvWfuxaM7BsycQONkjDBTPNu+lqctJBGnBv3A==\n-----END RSA PRIVATE KEY-----\n"))
key, _ := x509.ParsePKCS1PrivateKey(rawKey.Bytes)
signer := &Signer{key}
publicKey, err := signer.GetPublicKey()
assert.NoError(t, err)
assert.IsType(t, &rsa.PublicKey{}, publicKey)
})
t.Run("empty key", func(t *testing.T) {
signer := &Signer{}
publicKey, err := signer.GetPublicKey()
assert.Error(t, err, "Key is empty")
assert.Nil(t, publicKey)
})
}

7
utils/time.go Normal file
View File

@@ -0,0 +1,7 @@
package utils
import "time"
func UnixMillisecond(t time.Time) int64 {
return t.UnixNano() / int64(time.Millisecond)
}

16
utils/time_test.go Normal file
View 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))
}