Compare commits

...

47 Commits

Author SHA1 Message Date
ErickSkrauch
334e60ff2f Prepare 4.2.3 release 2019-10-03 01:26:34 +03:00
ErickSkrauch
6d6d0e4b79 Decrease queue batch size. Log all 400 response from the Mojang's API. Resolves #10. 2019-10-03 01:24:25 +03:00
ErickSkrauch
0cfed45b64 Prepare 4.2.2 release 2019-06-19 01:02:41 +03:00
ErickSkrauch
f872fe4698 Fix race condition, introduced in the previous commit 2019-06-19 00:56:09 +03:00
ErickSkrauch
5b4761e4e5 Fixes #9. Start GC loop for in-memory textures cache. 2019-06-18 23:34:16 +03:00
ErickSkrauch
e81ca1520d Add codecov shield [skip ci] 2019-05-06 17:26:55 +03:00
ErickSkrauch
d36fc77df0 Prepare 4.2.1 release 2019-05-06 17:20:52 +03:00
ErickSkrauch
ab78af33a5 Remove validation rules for a hash field 2019-05-06 17:17:44 +03:00
ErickSkrauch
1f057a27aa Adjust Mojang's queue behavior 2019-05-06 17:12:37 +03:00
ErickSkrauch
9dde5715f5 Adjust Mojang's queue behavior 2019-05-05 23:06:29 +03:00
ErickSkrauch
f3a8af6866 Upgrade Alpine version to 3.9, add ca-certificates 2019-05-02 21:55:21 +03:00
ErickSkrauch
e6bac323c5 Update changelog 2019-05-02 21:07:40 +03:00
ErickSkrauch
6515e3e5bd Resolves #5. Return Redis connection to the pool after commands are executed 2019-05-01 02:16:20 +03:00
ErickSkrauch
ed0b9bb040 Resolves #6. Remove hash field from the project structures 2019-05-01 02:16:11 +03:00
ErickSkrauch
a81c6fc9f8 Resolves #4. Fix Gopkg.toml structure, update all outdated dependencies, use middlewares introduced in gorilla/mux 1.6.1, replace gopkg.in/h2non/gock.v1 with it's GitHub link github.com/h2non/gock 2019-05-01 02:15:57 +03:00
ErickSkrauch
8aeb1929b5 Merge pull request #3 from elyby/1_mojang_skins_proxy
Restore Mojang skins proxy implementation
2019-05-01 00:56:11 +03:00
ErickSkrauch
b97647318f Enable codecov 2019-04-30 14:31:04 +03:00
KolFoxy
8d619d52cd #1: Fixed misspells in README and CHANGELOG
Co-Authored-By: erickskrauch <erickskrauch@ely.by>
2019-04-30 11:03:58 +03:00
ErickSkrauch
a5daae3cb8 #1: Add CHANGELOG.md, update README.md 2019-04-30 01:55:59 +03:00
ErickSkrauch
94b930f388 #1: Add test case for panic when trying to store response without textures 2019-04-30 00:45:29 +03:00
ErickSkrauch
f213ed45c7 #1: Log unexpected errors from Mojang API 2019-04-30 00:36:51 +03:00
ErickSkrauch
6daec4dc4b #1: Fix GolangCI issues 2019-04-28 20:30:55 +03:00
ErickSkrauch
90ce22f687 #1: Attempt to fix travis tests run 2019-04-28 20:24:08 +03:00
ErickSkrauch
9250d53fb3 #1: Remove comments about compatibility check with exists Authlibs 2019-04-28 20:21:46 +03:00
ErickSkrauch
2c7a1625f3 #1: Tests for http layer are restored 2019-04-28 00:43:22 +03:00
ErickSkrauch
f7cdab243f #1: Integrate queue to the application 2019-04-27 01:46:15 +03:00
ErickSkrauch
f3690686ec #1: Implement UuidsStorage in Redis 2019-04-25 02:23:10 +03:00
ErickSkrauch
533afcc689 #1: Add logging mechanic and remove awaiting of finishing of all textures requests in usernames queue 2019-04-25 00:45:04 +03:00
ErickSkrauch
50a19202a5 #1: Fix build 2019-04-21 20:35:35 +03:00
ErickSkrauch
d7f03ce182 #1: Implemented in-memory storage for textures 2019-04-21 20:28:58 +03:00
ErickSkrauch
ad300e8c1c #1: Implemented helper to decode/encode base64 textures value 2019-04-21 20:27:54 +03:00
ErickSkrauch
7d1506d0d9 #1: Fix Mojang's API HTTPClient default configuration, make mojang.ResponseError interface not applicable to any type, add handling of some possible network errors 2019-04-21 03:04:03 +03:00
ErickSkrauch
a8bbacf8b1 #1: Handle Mojang's server errors too 2019-04-20 23:04:29 +03:00
ErickSkrauch
c2921400b0 #1: Add case when Mojang's API returns empty response 2019-04-20 22:39:17 +03:00
ErickSkrauch
e7c0fac346 #1: Split textures processing to 2 separate steps 2019-04-20 22:22:02 +03:00
ErickSkrauch
bd099cfb2a #1: User golang 1.12 for travis build. Improve random usernames generator 2019-04-20 20:04:57 +03:00
ErickSkrauch
96af45b2a1 #1: Disallow to query invalid Mojang usernames 2019-04-20 19:51:55 +03:00
ErickSkrauch
b1e18d0d01 #1: Add storage integration 2019-04-20 19:35:37 +03:00
ErickSkrauch
abea94a41f #1: Add broadcaster structure to broadcast results of the same usernames 2019-04-20 03:23:49 +03:00
ErickSkrauch
8244351bb5 #1: Fix race conditions errors and rewrite tests 2019-04-19 01:41:52 +03:00
ErickSkrauch
e14619e079 #1: add initial tests for queue, upgrade github.com/stretchr/testify 2019-04-18 02:56:20 +03:00
ErickSkrauch
fd4e5eb9ca #1: Pull queue into struct, add storage interface 2019-04-15 02:52:00 +03:00
ErickSkrauch
879a33344b #1: Renaming 2019-04-15 01:32:22 +03:00
ErickSkrauch
d2d6d07fa6 #1: Rough implementation of textures queue 2019-04-15 00:52:10 +03:00
ErickSkrauch
44f3ee7413 #1: Improve uuidToTextures method, organize tests 2019-04-15 00:31:09 +03:00
ErickSkrauch
7db4d27fba #1: Implemented necessary Mojang APIs 2019-04-14 17:36:46 +03:00
ErickSkrauch
4386054ca1 Latest dep structure changes [skip ci] 2019-04-14 17:34:10 +03:00
40 changed files with 3739 additions and 1054 deletions

View File

@@ -2,7 +2,7 @@ sudo: required
language: go
go:
- 1.9
- 1.12
services:
- docker
@@ -20,7 +20,8 @@ jobs:
include:
- stage: test
script:
- go test -v -race ./...
- go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- bash <(curl -s https://codecov.io/bash)
- stage: deploy
script:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"

73
CHANGELOG.md Normal file
View File

@@ -0,0 +1,73 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - xxxx-xx-xx
## [4.2.3] - 2019-10-03
### Changed
- Mojang's textures queue batch size [reduced to 10](https://wiki.vg/index.php?title=Mojang_API&type=revision&diff=14964&oldid=14954).
- 400 BadRequest errors from the Mojang's API are now logged.
## [4.2.2] - 2019-06-19
### Fixed
- GC for in-memory textures cache has not been initialized.
## [4.2.1] - 2019-05-06
### Changed
- Improved Keep-Alive settings for HTTP client used to perform requests to Mojang's APIs.
- Mojang's textures queue now has static delay of 1 second after each iteration to prevent strange `429` errors.
- Mojang's textures queue now caches even errored responses for signed textures to avoid `429` errors.
- Mojang's textures queue now caches textures data for 70 seconds to avoid strange `429` errors.
- Mojang's textures queue now doesn't log timeout errors.
### Fixed
- Panic when Redis connection is broken.
- Duplication of Redis connections pool for Mojang's textures queue.
- Removed validation rules for `hash` field.
## [4.2.0] - 2019-05-02
### Added
- `CHANGELOG.md` file.
- [#1](https://github.com/elyby/chrly/issues/1): Restored Mojang skins proxy.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username`
- `ely.skinsystem.{hostname}.app.mojang_textures.request`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit_nil`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queued`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_miss`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.cache_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request`
- Gauges:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size`
- Timers:
- `ely.skinsystem.{hostname}.app.mojang_textures.result_time`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request_time`
### Changed
- Bumped Go version to 1.12.
- Bumped Alpine version to 3.9.3.
### Fixed
- `/textures` request no longer proxies request to Mojang in a case when there is no information about the skin,
but there is a cape.
- [#5](https://github.com/elyby/chrly/issues/5): Return Redis connection to the pool after commands are executed
### Removed
- `hash` field from `/textures` response because the game doesn't use it and calculates hash by getting the filename
from the textures link instead.
- `hash` field from `POST /api/skins` endpoint.
[Unreleased]: https://github.com/elyby/chrly/compare/4.2.3...HEAD
[4.2.3]: https://github.com/elyby/chrly/compare/4.2.2...4.2.3
[4.2.2]: https://github.com/elyby/chrly/compare/4.2.1...4.2.2
[4.2.1]: https://github.com/elyby/chrly/compare/4.2.0...4.2.1
[4.2.0]: https://github.com/elyby/chrly/compare/4.1.1...4.2.0

View File

@@ -1,7 +1,9 @@
FROM alpine:3.7
FROM alpine:3.9.3
EXPOSE 80
RUN apk add --no-cache ca-certificates
ENV STORAGE_REDIS_HOST=redis
ENV STORAGE_FILESYSTEM_HOST=/data

220
Gopkg.lock generated
View File

@@ -2,183 +2,333 @@
[[projects]]
digest = "1:8855efc2aff3afd6319da41b22a8ca1cfd1698af05a24852c01636ba65b133f0"
name = "github.com/SermoDigital/jose"
packages = [".","crypto","jws","jwt"]
packages = [
".",
"crypto",
"jws",
"jwt",
]
pruneopts = ""
revision = "f6df55f235c24f236d11dbcf665249a59ac2021f"
version = "1.1"
[[projects]]
digest = "1:c7b11da9bf0707e6920e1b361fbbbbe9b277ef3a198377baa4527f6e31049be0"
name = "github.com/certifi/gocertifi"
packages = ["."]
pruneopts = ""
revision = "3fd9e1adb12b72d2f3f82191d49be9b93c69f67c"
version = "2017.07.27"
[[projects]]
digest = "1:56c130d885a4aacae1dd9c7b71cfe39912c7ebc1ff7d2b46083c8812996dc43b"
name = "github.com/davecgh/go-spew"
packages = ["spew"]
pruneopts = ""
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
digest = "1:9f1e571696860f2b4f8a241b43ce91c6085e7aaed849ccca53f590a4dc7b95bd"
name = "github.com/fsnotify/fsnotify"
packages = ["."]
pruneopts = ""
revision = "629574ca2a5df945712d3079857300b5e4da0236"
version = "v1.4.2"
[[projects]]
branch = "master"
digest = "1:904b0b847f705de43c15e6c8f3dd639044db5601dedfb2f3fdb3021a28491d15"
name = "github.com/getsentry/raven-go"
packages = ["."]
revision = "d175f85701dfbf44cb0510114c9943e665e60907"
pruneopts = ""
revision = "919484f041ea21e7e27be291cee1d6af7bc98864"
[[projects]]
digest = "1:530233672f656641b365f8efb38ed9fba80e420baff2ce87633813ab3755ed6d"
name = "github.com/golang/mock"
packages = ["gomock"]
revision = "13f360950a79f5864a972c786a10a50e44b69541"
version = "v1.0.0"
[[projects]]
name = "github.com/gorilla/context"
packages = ["."]
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
version = "v1.1"
pruneopts = ""
revision = "51421b967af1f557f93a59e0057aaf15ca02e29c"
version = "v1.2.0"
[[projects]]
digest = "1:65c7ed49d9f36dd4752e43013323fa9229db60b29aa4f5a75aaecda3130c74e2"
name = "github.com/gorilla/mux"
packages = ["."]
revision = "bcd8bc72b08df0f70df986b97f95590779502d31"
version = "v1.4.0"
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/scanner","hcl/strconv","hcl/token","json/parser","json/scanner","json/token"]
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"]
revision = "d234cfb904a91daafa4e1f92599a893b349cc0c2"
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"]
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"]
packages = [
".",
"mem",
]
pruneopts = ""
revision = "ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b"
[[projects]]
digest = "1:6ff9b74bfea2625f805edec59395dc37e4a06458dd3c14e3372337e3d35a2ed6"
name = "github.com/spf13/cast"
packages = ["."]
pruneopts = ""
revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4"
version = "v1.1.0"
[[projects]]
branch = "master"
digest = "1:a1403cc8a94b8d7956ee5e9694badef0e7b051af289caad1cf668331e3ffa4f6"
name = "github.com/spf13/cobra"
packages = ["."]
revision = "0c34d16c3123764e413b9ed982ada58b1c3d53ea"
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 = ["."]
revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66"
version = "v1.0.0"
pruneopts = ""
revision = "298182f68c66c05229eb03ac171abe6e309ee79a"
version = "v1.0.3"
[[projects]]
digest = "1:90fe60ab6f827e308b0c8cc1e11dce8ff1e96a927c8b171271a3cb04dd517606"
name = "github.com/spf13/viper"
packages = ["."]
revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7"
version = "v1.0.0"
pruneopts = ""
revision = "9e56dacc08fbbf8c9ee2dbc717553c758ce42bc9"
version = "v1.3.2"
[[projects]]
name = "github.com/stretchr/testify"
packages = ["assert"]
revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0"
version = "v1.1.4"
[[projects]]
branch = "issue-18"
name = "github.com/thedevsaddam/govalidator"
digest = "1:711eebe744c0151a9d09af2315f0bb729b2ec7637ef4c410fa90a18ef74b65b6"
name = "github.com/stretchr/objx"
packages = ["."]
revision = "59055296916bb3c6ad9cf3b21d5f2cf7059f8e76"
source = "https://github.com/erickskrauch/govalidator.git"
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"]
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
inputs-digest = "e6bd87f630333e3e5b03bea33720c3281a9094551bd5ced436062157fe51ab71"
input-imports = [
"github.com/SermoDigital/jose/crypto",
"github.com/SermoDigital/jose/jws",
"github.com/getsentry/raven-go",
"github.com/golang/mock/gomock",
"github.com/gorilla/mux",
"github.com/h2non/gock",
"github.com/mediocregopher/radix.v2/pool",
"github.com/mediocregopher/radix.v2/redis",
"github.com/mediocregopher/radix.v2/util",
"github.com/mono83/slf",
"github.com/mono83/slf/rays",
"github.com/mono83/slf/recievers/sentry",
"github.com/mono83/slf/recievers/statsd",
"github.com/mono83/slf/recievers/writer",
"github.com/mono83/slf/wd",
"github.com/spf13/cobra",
"github.com/spf13/viper",
"github.com/stretchr/testify/assert",
"github.com/stretchr/testify/mock",
"github.com/stretchr/testify/suite",
"github.com/tevino/abool",
"github.com/thedevsaddam/govalidator",
]
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -2,23 +2,27 @@ ignored = ["github.com/elyby/chrly"]
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.4.0"
version = "^1.6.1"
[[constraint]]
name = "github.com/mediocregopher/radix.v2"
[[constraint]]
name = "github.com/mono83/slf"
[[constraint]]
name = "github.com/spf13/cobra"
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"
@@ -26,19 +30,22 @@ ignored = ["github.com/elyby/chrly"]
[[constraint]]
name = "github.com/thedevsaddam/govalidator"
source = "https://github.com/erickskrauch/govalidator.git"
branch = "issue-18"
version = "^1.9.6"
[[constraint]]
name = "github.com/tevino/abool"
branch = "master"
# Testing dependencies
[[constraint]]
name = "github.com/stretchr/testify"
version = "^1.1.4"
version = "^1.3.0"
[[constraint]]
name = "github.com/golang/mock"
version = "^1.0.0"
[[constraint]]
name = "gopkg.in/h2non/gock.v1"
name = "github.com/h2non/gock"
version = "^1.0.6"

View File

@@ -1,8 +1,15 @@
# Chrly
Chrly is a lightweight implementation of Minecraft skins system server. It's packaged and distributed as a Docker
image and can be downloaded from [Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can
withstand heavy loads and is production ready.
[![Written in Go][ico-lang]][link-go]
[![Build Status][ico-build]][link-build]
[![Coverage][ico-coverage]][link-coverage]
[![Keep a Changelog][ico-changelog]](CHANGELOG.md)
[![Software License][ico-license]](LICENSE)
Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's
skins system. It's packaged and distributed as a Docker image and can be downloaded from
[Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can withstand heavy loads and is
production ready.
## Installation
@@ -33,7 +40,8 @@ services:
- ./data/redis:/data
```
Chrly will mount some volumes on the host machine to persist storage for capes and Redis database.
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations.
### Config
@@ -64,13 +72,13 @@ Each endpoint that accepts `username` as a part of an url takes it case insensit
#### `GET /skins/{username}.png`
This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll
respond with the `301` redirect to that url. If there is no record for requested username, it'll redirect to the
Mojang skins system as: `http://skins.minecraft.net/MinecraftSkins/{username}.png` with the original username's case.
respond with the `301` redirect to that url. If the skin entry isn't found, it'll request textures information from
Mojang's API and if it has a skin, than it'll return a `301` redirect to it.
#### `GET /cloaks/{username}.png`
It responds to requested `username` with a cape texture. If user's cape file doesn't exists, then it'll redirect to the
Mojang skins system as: `http://skins.minecraft.net/MinecraftCloaks/{username}.png` with the original username's case.
It responds to requested `username` with a cape texture. If the cape entry isn't found, it'll request textures
information from Mojang's API and if it has a cape, than it'll return a `301` redirect to it.
#### `GET /textures/{username}`
@@ -79,22 +87,19 @@ This endpoint forms response payloads as if it was the `textures`' property, but
```json
{
"SKIN": {
"url": "http://ely.by/minecraft/skins/skin.png",
"hash": "55d2a8848764f5ff04012cdb093458bd",
"url": "http://example.com/skin.png",
"metadata": {
"model": "slim"
}
},
"CAPE": {
"url": "http://skinsystem.ely.by/cloaks/username",
"hash": "424ff79dce9940af89c28ad80de8aaad"
"url": "http://example.com/cape.png"
}
}
```
If record for the requested username wasn't found, cape would be omitted and skin would be formed for Mojang skins
system. Hash would be formed as the username plus the half-hour-ranged time of request, which is needed to improve
caching of Mojang skins inside Minecraft.
If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has
a textures property, than it'll return decoded contents.
That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
@@ -103,8 +108,8 @@ to the Chrly server and put the result in your hasJoined response.
#### `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 the Mojang signatures, then you can pass it with textures and it'll be displayed in this
method. Received response should be directly sent to the client without any modification via game server API.
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:
@@ -128,6 +133,10 @@ Response example:
If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent.
You can adjust URL to `/textures/signed/{username}?proxy=true` to obtain textures information for provided username
from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in
the example above.
#### `GET /skins?name={username}`
Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username
@@ -164,7 +173,6 @@ form data. `form-urlencoded` also supported, but, as you may know, it doesn't su
| username | string | Username. Case insensitive. |
| uuid | uuid | UUID of the user. |
| skinId | int | Skin identifier. |
| hash | string | Skin's hash. Algorithm can be any. For example `md5`. |
| is1_8 | bool | Does the skin have the new format (64x64). |
| isSlim | bool | Does skin have slim arms (Alex model). |
| mojangTextures | string | Mojang textures field. It must be a base64 encoded json string. Not required. |
@@ -250,3 +258,13 @@ 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 ./...`. If your Go version is older than 1.9, then run a `/script/test`.
[ico-lang]: https://img.shields.io/badge/lang-go%201.12-blue.svg?style=flat-square
[ico-build]: https://img.shields.io/travis/elyby/chrly.svg?style=flat-square
[ico-coverage]: https://img.shields.io/codecov/c/github/elyby/chrly.svg?style=flat-square
[ico-changelog]: https://img.shields.io/badge/keep%20a-changelog-orange.svg?style=flat-square
[ico-license]: https://img.shields.io/github/license/elyby/chrly.svg?style=flat-square
[link-go]: https://golang.org
[link-build]: https://travis-ci.org/elyby/chrly
[link-coverage]: https://codecov.io/gh/elyby/chrly

194
api/mojang/mojang.go Normal file
View File

@@ -0,0 +1,194 @@
package mojang
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"time"
)
var HttpClient = &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{
MaxIdleConnsPerHost: 1024,
},
}
type SignedTexturesResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
decodedTextures *TexturesProp
}
func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp {
if t.decodedTextures == nil {
var texturesProp string
for _, prop := range t.Props {
if prop.Name == "textures" {
texturesProp = prop.Value
break
}
}
if texturesProp == "" {
return nil
}
decodedTextures, _ := DecodeTextures(texturesProp)
t.decodedTextures = decodedTextures
}
return t.decodedTextures
}
type Property struct {
Name string `json:"name"`
Signature string `json:"signature,omitempty"`
Value string `json:"value"`
}
type ProfileInfo struct {
Id string `json:"id"`
Name string `json:"name"`
IsLegacy bool `json:"legacy,omitempty"`
IsDemo bool `json:"demo,omitempty"`
}
// Exchanges usernames array to array of uuids
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) {
requestBody, _ := json.Marshal(usernames)
request, _ := http.NewRequest("POST", "https://api.mojang.com/profiles/minecraft", bytes.NewBuffer(requestBody))
request.Header.Set("Content-Type", "application/json")
response, err := HttpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if responseErr := validateResponse(response); responseErr != nil {
return nil, responseErr
}
var result []*ProfileInfo
body, _ := ioutil.ReadAll(response.Body)
_ = json.Unmarshal(body, &result)
return result, nil
}
// Obtains textures information for provided uuid
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) {
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + uuid
if signed {
url += "?unsigned=false"
}
request, _ := http.NewRequest("GET", url, nil)
response, err := HttpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if responseErr := validateResponse(response); responseErr != nil {
return nil, responseErr
}
var result *SignedTexturesResponse
body, _ := ioutil.ReadAll(response.Body)
_ = json.Unmarshal(body, &result)
return result, nil
}
func validateResponse(response *http.Response) error {
switch {
case response.StatusCode == 204:
return &EmptyResponse{}
case response.StatusCode == 400:
type errorResponse struct {
Error string `json:"error"`
Message string `json:"errorMessage"`
}
var decodedError *errorResponse
body, _ := ioutil.ReadAll(response.Body)
_ = json.Unmarshal(body, &decodedError)
return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message}
case response.StatusCode == 429:
return &TooManyRequestsError{}
case response.StatusCode >= 500:
return &ServerError{Status: response.StatusCode}
}
return nil
}
type ResponseError interface {
IsMojangError() bool
}
// Mojang API doesn't return a 404 Not Found error for non-existent data identifiers
// Instead, they return 204 with an empty body
type EmptyResponse struct {
}
func (*EmptyResponse) Error() string {
return "Empty Response"
}
func (*EmptyResponse) IsMojangError() bool {
return true
}
// When passed request params are invalid, Mojang returns 400 Bad Request error
type BadRequestError struct {
ResponseError
ErrorType string
Message string
}
func (e *BadRequestError) Error() string {
return e.Message
}
func (*BadRequestError) IsMojangError() bool {
return true
}
// When you exceed the set limit of requests, this error will be returned
type TooManyRequestsError struct {
ResponseError
}
func (*TooManyRequestsError) Error() string {
return "Too Many Requests"
}
func (*TooManyRequestsError) IsMojangError() bool {
return true
}
// ServerError happens when Mojang's API returns any response with 50* status
type ServerError struct {
ResponseError
Status int
}
func (e *ServerError) Error() string {
return "Server error"
}
func (*ServerError) IsMojangError() bool {
return true
}

289
api/mojang/mojang_test.go Normal file
View File

@@ -0,0 +1,289 @@
package mojang
import (
"net/http"
"testing"
"github.com/h2non/gock"
testify "github.com/stretchr/testify/assert"
)
func TestSignedTexturesResponse(t *testing.T) {
t.Run("DecodeTextures", func(t *testing.T) {
obj := &SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock",
Props: []*Property{
{
Name: "textures",
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
},
},
}
textures := obj.DecodeTextures()
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
})
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
obj := &SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock",
Props: []*Property{},
}
textures := obj.DecodeTextures()
testify.Nil(t, textures)
})
}
func TestUsernamesToUuids(t *testing.T) {
t.Run("exchange usernames to uuids", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
JSON([]string{"Thinkofdeath", "maksimkurb"}).
Reply(200).
JSON([]map[string]interface{}{
{
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
"name": "Thinkofdeath",
"legacy": false,
"demo": true,
},
{
"id": "0d252b7218b648bfb86c2ae476954d32",
"name": "maksimkurb",
// There is no legacy or demo fields
},
})
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
if assert.NoError(err) {
assert.Len(result, 2)
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
assert.Equal("Thinkofdeath", result[0].Name)
assert.False(result[0].IsLegacy)
assert.True(result[0].IsDemo)
assert.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
assert.Equal("maksimkurb", result[1].Name)
assert.False(result[1].IsLegacy)
assert.False(result[1].IsDemo)
}
})
t.Run("handle bad request response", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(400).
JSON(map[string]interface{}{
"error": "IllegalArgumentException",
"errorMessage": "profileName can not be null or empty.",
})
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UsernamesToUuids([]string{""})
assert.Nil(result)
assert.IsType(&BadRequestError{}, err)
assert.EqualError(err, "profileName can not be null or empty.")
assert.Implements((*ResponseError)(nil), err)
})
t.Run("handle too many requests response", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(429).
JSON(map[string]interface{}{
"error": "TooManyRequestsException",
"errorMessage": "The client has sent too many requests within a certain amount of time",
})
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&TooManyRequestsError{}, err)
assert.EqualError(err, "Too Many Requests")
assert.Implements((*ResponseError)(nil), err)
})
t.Run("handle server error", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(500).
BodyString("500 Internal Server Error")
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&ServerError{}, err)
assert.EqualError(err, "Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})
}
func TestUuidToTextures(t *testing.T) {
t.Run("obtain not signed textures", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(200).
JSON(map[string]interface{}{
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
"name": "Thinkofdeath",
"properties": []interface{}{
map[string]interface{}{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
},
},
})
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
if assert.NoError(err) {
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
assert.Equal("Thinkofdeath", result.Name)
assert.Equal(1, len(result.Props))
assert.Equal("textures", result.Props[0].Name)
assert.Equal(476, len(result.Props[0].Value))
assert.Equal("", result.Props[0].Signature)
}
})
t.Run("obtain signed textures", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
MatchParam("unsigned", "false").
Reply(200).
JSON(map[string]interface{}{
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
"name": "Thinkofdeath",
"properties": []interface{}{
map[string]interface{}{
"name": "textures",
"signature": "signature string",
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
},
},
})
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", true)
if assert.NoError(err) {
assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
assert.Equal("Thinkofdeath", result.Name)
assert.Equal(1, len(result.Props))
assert.Equal("textures", result.Props[0].Name)
assert.Equal(476, len(result.Props[0].Value))
assert.Equal("signature string", result.Props[0].Signature)
}
})
t.Run("handle empty response", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(204).
BodyString("")
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&EmptyResponse{}, err)
assert.EqualError(err, "Empty Response")
assert.Implements((*ResponseError)(nil), err)
})
t.Run("handle too many requests response", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(429).
JSON(map[string]interface{}{
"error": "TooManyRequestsException",
"errorMessage": "The client has sent too many requests within a certain amount of time",
})
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&TooManyRequestsError{}, err)
assert.EqualError(err, "Too Many Requests")
assert.Implements((*ResponseError)(nil), err)
})
t.Run("handle server error", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(500).
BodyString("500 Internal Server Error")
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false)
assert.Nil(result)
assert.IsType(&ServerError{}, err)
assert.EqualError(err, "Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})
}

View File

@@ -0,0 +1,53 @@
package queue
import (
"sync"
"github.com/elyby/chrly/api/mojang"
)
type broadcastMap struct {
lock sync.Mutex
listeners map[string][]chan *mojang.SignedTexturesResponse
}
func newBroadcaster() *broadcastMap {
return &broadcastMap{
listeners: make(map[string][]chan *mojang.SignedTexturesResponse),
}
}
// Returns a boolean value, which will be true if the username passed didn't exist before
func (c *broadcastMap) AddListener(username string, resultChan chan *mojang.SignedTexturesResponse) bool {
c.lock.Lock()
defer c.lock.Unlock()
val, alreadyHasSource := c.listeners[username]
if alreadyHasSource {
c.listeners[username] = append(val, resultChan)
return false
}
c.listeners[username] = []chan *mojang.SignedTexturesResponse{resultChan}
return true
}
func (c *broadcastMap) BroadcastAndRemove(username string, result *mojang.SignedTexturesResponse) {
c.lock.Lock()
defer c.lock.Unlock()
val, ok := c.listeners[username]
if !ok {
return
}
for _, channel := range val {
go func(channel chan *mojang.SignedTexturesResponse) {
channel <- result
close(channel)
}(channel)
}
delete(c.listeners, username)
}

View File

@@ -0,0 +1,75 @@
package queue
import (
"github.com/elyby/chrly/api/mojang"
testify "github.com/stretchr/testify/assert"
"testing"
)
func TestBroadcastMap_GetOrAppend(t *testing.T) {
t.Run("first call when username didn't exist before should return true", func(t *testing.T) {
assert := testify.New(t)
broadcaster := newBroadcaster()
channel := make(chan *mojang.SignedTexturesResponse)
isFirstListener := broadcaster.AddListener("mock", channel)
assert.True(isFirstListener)
listeners, ok := broadcaster.listeners["mock"]
assert.True(ok)
assert.Len(listeners, 1)
assert.Equal(channel, listeners[0])
})
t.Run("subsequent calls should return false", func(t *testing.T) {
assert := testify.New(t)
broadcaster := newBroadcaster()
channel1 := make(chan *mojang.SignedTexturesResponse)
isFirstListener := broadcaster.AddListener("mock", channel1)
assert.True(isFirstListener)
channel2 := make(chan *mojang.SignedTexturesResponse)
isFirstListener = broadcaster.AddListener("mock", channel2)
assert.False(isFirstListener)
channel3 := make(chan *mojang.SignedTexturesResponse)
isFirstListener = broadcaster.AddListener("mock", channel3)
assert.False(isFirstListener)
})
}
func TestBroadcastMap_BroadcastAndRemove(t *testing.T) {
t.Run("should broadcast to all listeners and remove the key", func(t *testing.T) {
assert := testify.New(t)
broadcaster := newBroadcaster()
channel1 := make(chan *mojang.SignedTexturesResponse)
channel2 := make(chan *mojang.SignedTexturesResponse)
broadcaster.AddListener("mock", channel1)
broadcaster.AddListener("mock", channel2)
result := &mojang.SignedTexturesResponse{Id: "mockUuid"}
broadcaster.BroadcastAndRemove("mock", result)
assert.Equal(result, <-channel1)
assert.Equal(result, <-channel2)
channel3 := make(chan *mojang.SignedTexturesResponse)
isFirstListener := broadcaster.AddListener("mock", channel3)
assert.True(isFirstListener)
})
t.Run("call on not exists username", func(t *testing.T) {
assert := testify.New(t)
assert.NotPanics(func() {
broadcaster := newBroadcaster()
broadcaster.BroadcastAndRemove("mock", &mojang.SignedTexturesResponse{})
})
})
}

View File

@@ -0,0 +1,112 @@
package queue
import (
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/tevino/abool"
)
var inMemoryStorageGCPeriod = 10 * time.Second
var inMemoryStoragePersistPeriod = time.Minute + 10*time.Second
var now = time.Now
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
}
type inMemoryTexturesStorage struct {
lock sync.Mutex
data map[string]*inMemoryItem
working *abool.AtomicBool
}
func CreateInMemoryTexturesStorage() *inMemoryTexturesStorage {
storage := &inMemoryTexturesStorage{
data: make(map[string]*inMemoryItem),
}
return storage
}
func (s *inMemoryTexturesStorage) Start() {
if s.working == nil {
s.working = abool.New()
}
if !s.working.IsSet() {
go func() {
time.Sleep(inMemoryStorageGCPeriod)
// TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right
for s.working.IsSet() {
start := time.Now()
s.gc()
time.Sleep(inMemoryStorageGCPeriod - time.Since(start))
}
}()
}
s.working.Set()
}
func (s *inMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *inMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.Lock()
defer s.lock.Unlock()
item, exists := s.data[uuid]
validRange := getMinimalNotExpiredTimestamp()
if !exists || validRange > item.timestamp {
return nil, &ValueNotFound{}
}
return item.textures, nil
}
func (s *inMemoryTexturesStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
var timestamp int64
if textures != nil {
decoded := textures.DecodeTextures()
if decoded == nil {
panic("unable to decode textures")
}
timestamp = decoded.Timestamp
} else {
timestamp = unixNanoToUnixMicro(now().UnixNano())
}
s.lock.Lock()
defer s.lock.Unlock()
s.data[uuid] = &inMemoryItem{
textures: textures,
timestamp: timestamp,
}
}
func (s *inMemoryTexturesStorage) gc() {
s.lock.Lock()
defer s.lock.Unlock()
maxTime := getMinimalNotExpiredTimestamp()
for uuid, value := range s.data {
if maxTime > value.timestamp {
delete(s.data, uuid)
}
}
}
func getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(inMemoryStoragePersistPeriod * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
}

View File

@@ -0,0 +1,200 @@
package queue
import (
"time"
"github.com/elyby/chrly/api/mojang"
testify "github.com/stretchr/testify/assert"
"testing"
)
var texturesWithSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{
Skin: &mojang.SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
},
},
}),
},
},
}
var texturesWithoutSkin = &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
func TestInMemoryTexturesStorage_GetTextures(t *testing.T) {
t.Run("get error when uuid is not exists", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
})
t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
now = func() time.Time {
return time.Now().Add(time.Minute * 2)
}
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Error(err, "value not found in the storage")
now = time.Now
})
}
func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) {
t.Run("store textures for previously not existed uuid", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("override already existed textures for uuid", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(texturesWithoutSkin, result)
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Nil(err)
})
t.Run("should panic if textures prop is not decoded", func(t *testing.T) {
assert := testify.New(t)
toStore := &mojang.SignedTexturesResponse{
Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
Name: "mock",
Props: []*mojang.Property{},
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
inMemoryStorageGCPeriod = 10 * time.Millisecond
inMemoryStoragePersistPeriod = 10 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
storage := CreateInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let it start first iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Error(textures1Err)
assert.Error(textures2Err)
storage.Stop()
}

View File

@@ -0,0 +1,56 @@
// Based on the implementation from https://flaviocopes.com/golang-data-structure-queue/
package queue
import (
"sync"
"github.com/elyby/chrly/api/mojang"
)
type jobItem struct {
Username string
RespondTo chan *mojang.SignedTexturesResponse
}
type jobsQueue struct {
lock sync.Mutex
items []*jobItem
}
func (s *jobsQueue) New() *jobsQueue {
s.items = []*jobItem{}
return s
}
func (s *jobsQueue) Enqueue(t *jobItem) {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, t)
}
func (s *jobsQueue) Dequeue(n int) []*jobItem {
s.lock.Lock()
defer s.lock.Unlock()
if n > s.Size() {
n = s.Size()
}
items := s.items[0:n]
s.items = s.items[n:len(s.items)]
return items
}
func (s *jobsQueue) IsEmpty() bool {
s.lock.Lock()
defer s.lock.Unlock()
return len(s.items) == 0
}
func (s *jobsQueue) Size() int {
return len(s.items)
}

View File

@@ -0,0 +1,47 @@
package queue
import (
"testing"
testify "github.com/stretchr/testify/assert"
)
func TestEnqueue(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{Username: "username1"})
s.Enqueue(&jobItem{Username: "username2"})
s.Enqueue(&jobItem{Username: "username3"})
assert.Equal(3, s.Size())
}
func TestDequeueN(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{Username: "username1"})
s.Enqueue(&jobItem{Username: "username2"})
s.Enqueue(&jobItem{Username: "username3"})
s.Enqueue(&jobItem{Username: "username4"})
items := s.Dequeue(2)
assert.Len(items, 2)
assert.Equal("username1", items[0].Username)
assert.Equal("username2", items[1].Username)
assert.Equal(2, s.Size())
items = s.Dequeue(40)
assert.Len(items, 2)
assert.Equal("username3", items[0].Username)
assert.Equal("username4", items[1].Username)
assert.True(s.IsEmpty())
}
func createQueue() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}

215
api/mojang/queue/queue.go Normal file
View File

@@ -0,0 +1,215 @@
package queue
import (
"net"
"net/url"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
var usernamesToUuids = mojang.UsernamesToUuids
var uuidToTextures = mojang.UuidToTextures
var uuidsQueueIterationDelay = time.Second
var forever = func() bool {
return true
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
type JobsQueue struct {
Storage Storage
Logger wd.Watchdog
onFirstCall sync.Once
queue jobsQueue
broadcast *broadcastMap
}
func (ctx *JobsQueue) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse {
// TODO: convert username to lower case
ctx.onFirstCall.Do(func() {
ctx.queue.New()
ctx.broadcast = newBroadcaster()
ctx.startQueue()
})
responseChan := make(chan *mojang.SignedTexturesResponse)
if !allowedUsernamesRegex.MatchString(username) {
ctx.Logger.IncCounter("mojang_textures.invalid_username", 1)
go func() {
responseChan <- nil
close(responseChan)
}()
return responseChan
}
ctx.Logger.IncCounter("mojang_textures.request", 1)
uuid, err := ctx.Storage.GetUuid(username)
if err == nil && uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit_nil", 1)
go func() {
responseChan <- nil
close(responseChan)
}()
return responseChan
}
isFirstListener := ctx.broadcast.AddListener(username, responseChan)
if isFirstListener {
start := time.Now()
// TODO: respond nil if processing takes more than 5 seconds
resultChan := make(chan *mojang.SignedTexturesResponse)
if uuid == "" {
ctx.Logger.IncCounter("mojang_textures.usernames.queued", 1)
ctx.queue.Enqueue(&jobItem{username, resultChan})
} else {
ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit", 1)
go func() {
resultChan <- ctx.getTextures(uuid)
}()
}
go func() {
result := <-resultChan
close(resultChan)
ctx.broadcast.BroadcastAndRemove(username, result)
ctx.Logger.RecordTimer("mojang_textures.result_time", time.Since(start))
}()
} else {
ctx.Logger.IncCounter("mojang_textures.already_in_queue", 1)
}
return responseChan
}
func (ctx *JobsQueue) startQueue() {
go func() {
time.Sleep(uuidsQueueIterationDelay)
for forever() {
start := time.Now()
ctx.queueRound()
elapsed := time.Since(start)
ctx.Logger.RecordTimer("mojang_textures.usernames.round_time", elapsed)
time.Sleep(uuidsQueueIterationDelay)
}
}()
}
func (ctx *JobsQueue) queueRound() {
if ctx.queue.IsEmpty() {
return
}
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(10)
ctx.Logger.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(jobs)))
ctx.Logger.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize-len(jobs)))
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.Username)
}
profiles, err := usernamesToUuids(usernames)
if err != nil {
ctx.handleResponseError(err, "usernames")
for _, job := range jobs {
job.RespondTo <- nil
}
return
}
for _, job := range jobs {
go func(job *jobItem) {
var uuid string
// The profiles in the response are not ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.Username, profile.Name) {
uuid = profile.Id
break
}
}
_ = ctx.Storage.StoreUuid(job.Username, uuid)
if uuid == "" {
job.RespondTo <- nil
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_miss", 1)
} else {
job.RespondTo <- ctx.getTextures(uuid)
ctx.Logger.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
}(job)
}
}
func (ctx *JobsQueue) getTextures(uuid string) *mojang.SignedTexturesResponse {
existsTextures, err := ctx.Storage.GetTextures(uuid)
if err == nil {
ctx.Logger.IncCounter("mojang_textures.textures.cache_hit", 1)
return existsTextures
}
ctx.Logger.IncCounter("mojang_textures.textures.request", 1)
start := time.Now()
result, err := uuidToTextures(uuid, true)
ctx.Logger.RecordTimer("mojang_textures.textures.request_time", time.Since(start))
if err != nil {
ctx.handleResponseError(err, "textures")
}
// Mojang can respond with an error, but count it as a hit, so store result even if the textures is nil
ctx.Storage.StoreTextures(uuid, result)
return result
}
func (ctx *JobsQueue) handleResponseError(err error, threadName string) {
ctx.Logger.Debug(":name: Got response error :err", wd.NameParam(threadName), wd.ErrParam(err))
switch err.(type) {
case mojang.ResponseError:
if _, ok := err.(*mojang.TooManyRequestsError); ok {
ctx.Logger.Warning(":name: Got 429 Too Many Requests :err", wd.NameParam(threadName), wd.ErrParam(err))
return
}
if _, ok := err.(*mojang.BadRequestError); ok {
ctx.Logger.Warning(":name: Got 400 Bad Request :err", wd.NameParam(threadName), wd.ErrParam(err))
return
}
return
case net.Error:
if err.(net.Error).Timeout() {
return
}
if _, ok := err.(*url.Error); ok {
return
}
if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") {
return
}
if err == syscall.ECONNREFUSED {
return
}
}
ctx.Logger.Emergency(":name: Unknown Mojang response error: :err", wd.NameParam(threadName), wd.ErrParam(err))
}

View File

@@ -0,0 +1,522 @@
package queue
import (
"crypto/rand"
"encoding/base64"
"errors"
"net"
"net/url"
"strings"
"syscall"
"time"
"github.com/elyby/chrly/api/mojang"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
mocks "github.com/elyby/chrly/tests"
)
type mojangApiMocks struct {
mock.Mock
}
func (o *mojangApiMocks) UsernamesToUuids(usernames []string) ([]*mojang.ProfileInfo, error) {
args := o.Called(usernames)
var result []*mojang.ProfileInfo
if casted, ok := args.Get(0).([]*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
func (o *mojangApiMocks) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) {
args := o.Called(uuid, signed)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type mockStorage struct {
mock.Mock
}
func (m *mockStorage) GetUuid(username string) (string, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
}
func (m *mockStorage) StoreUuid(username string, uuid string) error {
args := m.Called(username, uuid)
return args.Error(0)
}
func (m *mockStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
func (m *mockStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
m.Called(uuid, textures)
}
type queueTestSuite struct {
suite.Suite
Queue *JobsQueue
Storage *mockStorage
MojangApi *mojangApiMocks
Logger *mocks.WdMock
Iterate func()
iterateChan chan bool
done func()
}
func (suite *queueTestSuite) SetupSuite() {
uuidsQueueIterationDelay = 0
}
func (suite *queueTestSuite) SetupTest() {
suite.Storage = &mockStorage{}
suite.Logger = &mocks.WdMock{}
suite.Queue = &JobsQueue{Storage: suite.Storage, Logger: suite.Logger}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.MojangApi = new(mojangApiMocks)
usernamesToUuids = suite.MojangApi.UsernamesToUuids
uuidToTextures = suite.MojangApi.UuidToTextures
}
func (suite *queueTestSuite) TearDownTest() {
suite.done()
time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls
suite.MojangApi.AssertExpectations(suite.T())
suite.Storage.AssertExpectations(suite.T())
suite.Logger.AssertExpectations(suite.T())
}
func (suite *queueTestSuite) TestReceiveTexturesForOneUsernameWithoutAnyCache() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result)
}
func (suite *queueTestSuite) TestReceiveTexturesForFewUsernamesWithoutAnyCache() {
expectedResult1 := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
expectedResult2 := &mojang.SignedTexturesResponse{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Twice()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Twice()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Twice()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Twice()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
suite.Storage.On("GetUuid", "Thinkofdeath").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("StoreUuid", "Thinkofdeath", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("GetTextures", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult1).Once()
suite.Storage.On("StoreTextures", "4566e69fc90748ee8d71d7ba5aa00d20", expectedResult2).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb", "Thinkofdeath"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult1, nil)
suite.MojangApi.On("UuidToTextures", "4566e69fc90748ee8d71d7ba5aa00d20", true).Once().Return(expectedResult2, nil)
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
resultChan2 := suite.Queue.GetTexturesForUsername("Thinkofdeath")
suite.Iterate()
suite.Assert().Equal(expectedResult1, <-resultChan1)
suite.Assert().Equal(expectedResult2, <-resultChan2)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUuid() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil)
// Storage.StoreUuid shouldn't be called
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
// MojangApi.UsernamesToUuids shouldn't be called
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that there is no iteration
result := <-resultChan
suite.Assert().Equal(expectedResult, result)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithFullyCachedResult() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.textures.cache_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil)
// Storage.StoreUuid shouldn't be called
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(expectedResult, nil)
// Storage.StoreTextures shouldn't be called
// MojangApi.UsernamesToUuids shouldn't be called
// MojangApi.UuidToTextures shouldn't be called
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that there is no iteration
result := <-resultChan
suite.Assert().Equal(expectedResult, result)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUnknownUuid() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", nil)
// Storage.StoreUuid shouldn't be called
// Storage.GetTextures shouldn't be called
// Storage.StoreTextures shouldn't be called
// MojangApi.UsernamesToUuids shouldn't be called
// MojangApi.UuidToTextures shouldn't be called
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that there is no iteration
suite.Assert().Nil(<-resultChan)
}
func (suite *queueTestSuite) TestReceiveTexturesForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Times(12)
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Times(12)
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(10)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(2)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Times(12)
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Times(12)
suite.Storage.On("GetUuid", mock.Anything).Times(12).Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", mock.Anything, "").Times(12).Return(nil) // should be called with "" if username is not compared to uuid
// Storage.GetTextures and Storage.SetTextures shouldn't be called
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return([]*mojang.ProfileInfo{}, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return([]*mojang.ProfileInfo{}, nil)
channels := make([]chan *mojang.SignedTexturesResponse, 12)
for i, username := range usernames {
channels[i] = suite.Queue.GetTexturesForUsername(username)
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
}
func (suite *queueTestSuite) TestReceiveTexturesForTheSameUsernames() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil)
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Equal(expectedResult, <-resultChan1)
suite.Assert().Equal(expectedResult, <-resultChan2)
}
func (suite *queueTestSuite) TestReceiveTexturesForUsernameThatAlreadyProcessing() {
expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", expectedResult).Once()
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).
Once().
After(10*time.Millisecond). // Simulate long round trip
Return(expectedResult, nil)
resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb")
// Note that for entire test there is only one iteration
suite.Iterate()
// Let it meet delayed UuidToTextures request
time.Sleep(5 * time.Millisecond)
resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Assert().Equal(expectedResult, <-resultChan1)
suite.Assert().Equal(expectedResult, <-resultChan2)
}
func (suite *queueTestSuite) TestDoNothingWhenNoTasks() {
suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once()
suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once()
suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything)
suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Once()
suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "").Once().Return(nil)
// Storage.GetTextures and Storage.StoreTextures shouldn't be called
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finish
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
// Let it to perform a few more iterations to ensure, that there is no calls to external APIs
suite.Iterate()
suite.Iterate()
}
type timeoutError struct {
}
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
var expectedErrors = []error{
&mojang.BadRequestError{},
&mojang.TooManyRequestsError{},
&mojang.ServerError{},
&timeoutError{},
&url.Error{Op: "GET", URL: "http://localhost"},
&net.OpError{Op: "read"},
&net.OpError{Op: "dial"},
syscall.ECONNREFUSED,
}
func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 400 Bad Request :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
for _, err := range expectedErrors {
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, err)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
suite.MojangApi.AssertExpectations(suite.T())
suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUsernameToUuidRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, errors.New("unexpected error"))
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
}
func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Times(len(expectedErrors))
suite.Logger.On("Warning", ":name: Got 400 Bad Request :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Warning", ":name: Got 429 Too Many Requests :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", (*mojang.SignedTexturesResponse)(nil))
for _, err := range expectedErrors {
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, err)
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
suite.MojangApi.AssertExpectations(suite.T())
suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364
}
}
func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUuidToTexturesRequest() {
suite.Logger.On("IncCounter", mock.Anything, mock.Anything)
suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything)
suite.Logger.On("RecordTimer", mock.Anything, mock.Anything)
suite.Logger.On("Debug", ":name: Got response error :err", mock.Anything, mock.Anything).Once()
suite.Logger.On("Emergency", ":name: Unknown Mojang response error: :err", mock.Anything, mock.Anything).Once()
suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Return(nil)
suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "0d252b7218b648bfb86c2ae476954d32", (*mojang.SignedTexturesResponse)(nil))
suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{
{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"},
}, nil)
suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, errors.New("unexpected error"))
resultChan := suite.Queue.GetTexturesForUsername("maksimkurb")
suite.Iterate()
suite.Assert().Nil(<-resultChan)
}
func (suite *queueTestSuite) TestReceiveTexturesForNotAllowedMojangUsername() {
suite.Logger.On("IncCounter", "mojang_textures.invalid_username", int64(1)).Once()
resultChan := suite.Queue.GetTexturesForUsername("Not allowed")
suite.Assert().Nil(<-resultChan)
}
func TestJobsQueueSuite(t *testing.T) {
suite.Run(t, new(queueTestSuite))
}
var replacer = strings.NewReplacer("-", "_", "=", "")
// https://stackoverflow.com/a/50581165
func randStr(len int) string {
buff := make([]byte, len)
_, _ = rand.Read(buff)
str := replacer.Replace(base64.URLEncoding.EncodeToString(buff))
// Base 64 can be longer than len
return str[:len]
}

View File

@@ -0,0 +1,53 @@
package queue
import "github.com/elyby/chrly/api/mojang"
type UuidsStorage interface {
GetUuid(username string) (string, error)
StoreUuid(username string, uuid string) error
}
// nil value can be passed to the storage to indicate that there is no textures
// for uuid and we know about it. Return err only in case, when storage completely
// unable to load any information about textures
type TexturesStorage interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
StoreTextures(uuid string, textures *mojang.SignedTexturesResponse)
}
type Storage interface {
UuidsStorage
TexturesStorage
}
// SplittedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SplittedStorage struct {
UuidsStorage
TexturesStorage
}
func (s *SplittedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
}
func (s *SplittedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
}
func (s *SplittedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
return s.TexturesStorage.GetTextures(uuid)
}
func (s *SplittedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -0,0 +1,89 @@
package queue
import (
"github.com/elyby/chrly/api/mojang"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
)
type uuidsStorageMock struct {
mock.Mock
}
func (m *uuidsStorageMock) GetUuid(username string) (string, error) {
args := m.Called(username)
return args.String(0), args.Error(1)
}
func (m *uuidsStorageMock) StoreUuid(username string, uuid string) error {
m.Called(username, uuid)
return nil
}
type texturesStorageMock struct {
mock.Mock
}
func (m *texturesStorageMock) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(uuid)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
func (m *texturesStorageMock) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
m.Called(uuid, textures)
}
func TestSplittedStorage(t *testing.T) {
createMockedStorage := func() (*SplittedStorage, *uuidsStorageMock, *texturesStorageMock) {
uuidsStorage := &uuidsStorageMock{}
texturesStorage := &texturesStorageMock{}
return &SplittedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage
}
t.Run("GetUuid", func(t *testing.T) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("GetUuid", "username").Once().Return("find me", nil)
result, err := storage.GetUuid("username")
assert.Nil(t, err)
assert.Equal(t, "find me", result)
uuidsMock.AssertExpectations(t)
})
t.Run("StoreUuid", func(t *testing.T) {
storage, uuidsMock, _ := createMockedStorage()
uuidsMock.On("StoreUuid", "username", "result").Once()
_ = storage.StoreUuid("username", "result")
uuidsMock.AssertExpectations(t)
})
t.Run("GetTextures", func(t *testing.T) {
result := &mojang.SignedTexturesResponse{Id: "mock id"}
storage, _, texturesMock := createMockedStorage()
texturesMock.On("GetTextures", "uuid").Once().Return(result, nil)
returned, err := storage.GetTextures("uuid")
assert.Nil(t, err)
assert.Equal(t, result, returned)
texturesMock.AssertExpectations(t)
})
t.Run("StoreTextures", func(t *testing.T) {
toStore := &mojang.SignedTexturesResponse{}
storage, _, texturesMock := createMockedStorage()
texturesMock.On("StoreTextures", "mock id", toStore).Once()
storage.StoreTextures("mock id", toStore)
texturesMock.AssertExpectations(t)
})
}
func TestValueNotFound_Error(t *testing.T) {
err := &ValueNotFound{}
assert.Equal(t, "value not found in the storage", err.Error())
}

51
api/mojang/textures.go Normal file
View File

@@ -0,0 +1,51 @@
package mojang
import (
"encoding/base64"
"encoding/json"
)
type TexturesProp struct {
Timestamp int64 `json:"timestamp"`
ProfileID string `json:"profileId"`
ProfileName string `json:"profileName"`
Textures *TexturesResponse `json:"textures"`
}
type TexturesResponse struct {
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
}
type SkinTexturesResponse struct {
Url string `json:"url"`
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
}
type SkinTexturesMetadata struct {
Model string `json:"model"`
}
type CapeTexturesResponse struct {
Url string `json:"url"`
}
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
if err != nil {
return nil, err
}
var result *TexturesProp
err = json.Unmarshal(jsonStr, &result)
if err != nil {
return nil, err
}
return result, nil
}
func EncodeTextures(textures *TexturesProp) string {
jsonSerialized, _ := json.Marshal(textures)
return base64.URLEncoding.EncodeToString(jsonSerialized)
}

112
api/mojang/textures_test.go Normal file
View File

@@ -0,0 +1,112 @@
package mojang
import (
testify "github.com/stretchr/testify/assert"
"testing"
)
type texturesTestCase struct {
Name string
Encoded string
Decoded *TexturesProp
}
var texturesTestCases = []*texturesTestCase{
{
Name: "property without textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
Decoded: &TexturesProp{
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
ProfileName: "ErickSkrauch",
Timestamp: int64(1555856010494),
Textures: &TexturesResponse{},
},
},
{
Name: "property with classic skin textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
Decoded: &TexturesProp{
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
ProfileName: "ErickSkrauch",
Timestamp: int64(1555856307412),
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
},
},
},
},
{
Name: "property with alex skin textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
Decoded: &TexturesProp{
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
ProfileName: "ErickSkrauch",
Timestamp: int64(1555856494791),
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
Metadata: &SkinTexturesMetadata{
Model: "slim",
},
},
},
},
},
{
Name: "property with skin and cape textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
Decoded: &TexturesProp{
ProfileID: "d90b68bc81724329a047f1186dcd4336",
ProfileName: "akronman1",
Timestamp: int64(1555857675335),
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
},
Cape: &CapeTexturesResponse{
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
},
},
},
},
}
func TestDecodeTextures(t *testing.T) {
for _, testCase := range texturesTestCases {
t.Run("decode "+testCase.Name, func(t *testing.T) {
assert := testify.New(t)
result, err := DecodeTextures(testCase.Encoded)
assert.Nil(err)
assert.Equal(testCase.Decoded, result)
})
}
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
assert := testify.New(t)
result, err := DecodeTextures("invalid base64")
assert.Error(err)
assert.Nil(result)
})
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
assert := testify.New(t)
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
assert.Error(err)
assert.Nil(result)
})
}
func TestEncodeTextures(t *testing.T) {
for _, testCase := range texturesTestCases {
t.Run("encode "+testCase.Name, func(t *testing.T) {
assert := testify.New(t)
result := EncodeTextures(testCase.Decoded)
assert.Equal(testCase.Encoded, result)
})
}
}

View File

@@ -4,11 +4,11 @@ import (
"fmt"
"log"
"github.com/elyby/chrly/auth"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/http"
@@ -27,7 +27,8 @@ var serveCmd = &cobra.Command{
storageFactory := db.StorageFactory{Config: viper.GetViper()}
logger.Info("Initializing skins repository")
skinsRepo, err := storageFactory.CreateFactory("redis").CreateSkinsRepository()
redisFactory := storageFactory.CreateFactory("redis")
skinsRepo, err := redisFactory.CreateSkinsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err))
return
@@ -35,19 +36,39 @@ var serveCmd = &cobra.Command{
logger.Info("Skins repository successfully initialized")
logger.Info("Initializing capes repository")
capesRepo, err := storageFactory.CreateFactory("filesystem").CreateCapesRepository()
filesystemFactory := storageFactory.CreateFactory("filesystem")
capesRepo, err := filesystemFactory.CreateCapesRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err))
return
}
logger.Info("Capes repository successfully initialized")
logger.Info("Preparing Mojang's textures queue")
mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating mojang uuids repo: %v", err))
return
}
texturesStorage := queue.CreateInMemoryTexturesStorage()
texturesStorage.Start()
mojangTexturesQueue := &queue.JobsQueue{
Logger: logger,
Storage: &queue.SplittedStorage{
UuidsStorage: mojangUuidsRepository,
TexturesStorage: texturesStorage,
},
}
logger.Info("Mojang's textures queue is successfully initialized")
cfg := &http.Config{
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Logger: logger,
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
MojangTexturesQueue: mojangTexturesQueue,
Logger: logger,
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
}
if err := cfg.Run(); err != nil {

View File

@@ -3,6 +3,7 @@ package db
import (
"github.com/spf13/viper"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/interfaces"
)
@@ -13,6 +14,7 @@ type StorageFactory struct {
type RepositoriesCreator interface {
CreateSkinsRepository() (interfaces.SkinsRepository, error)
CreateCapesRepository() (interfaces.CapesRepository, error)
CreateMojangUuidsRepository() (queue.UuidsStorage, error)
}
func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator {
@@ -25,7 +27,7 @@ func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator
}
case "filesystem":
return &FilesystemFactory{
BasePath : factory.Config.GetString("storage.filesystem.basePath"),
BasePath: factory.Config.GetString("storage.filesystem.basePath"),
CapesDirName: factory.Config.GetString("storage.filesystem.capesDirName"),
}
}

View File

@@ -5,12 +5,13 @@ import (
"path"
"strings"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
)
type FilesystemFactory struct {
BasePath string
BasePath string
CapesDirName string
}
@@ -26,6 +27,10 @@ func (f FilesystemFactory) CreateCapesRepository() (interfaces.CapesRepository,
return &filesStorage{path: path.Join(f.BasePath, f.CapesDirName)}, nil
}
func (f FilesystemFactory) CreateMojangUuidsRepository() (queue.UuidsStorage, error) {
panic("implement me")
}
func (f FilesystemFactory) validateFactoryConfig() error {
if f.BasePath == "" {
return &ParamRequired{"basePath"}
@@ -47,7 +52,7 @@ func (repository *filesStorage) FindByUsername(username string) (*model.Cape, er
return nil, &CapeNotFoundError{username}
}
capePath := path.Join(repository.path, strings.ToLower(username) + ".png")
capePath := path.Join(repository.path, strings.ToLower(username)+".png")
file, err := os.Open(capePath)
if err != nil {
return nil, &CapeNotFoundError{username}

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"strconv"
"strings"
"time"
@@ -14,34 +14,41 @@ import (
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix.v2/util"
"github.com/elyby/chrly/api/mojang/queue"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
)
type RedisFactory struct {
Host string
Port int
PoolSize int
connection *pool.Pool
Host string
Port int
PoolSize int
pool *pool.Pool
}
// TODO: maybe we should manually return connection to the pool?
func (f *RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
return f.createInstance()
}
func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
connection, err := f.getConnection()
func (f *RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
panic("capes repository not supported for this storage type")
}
func (f *RedisFactory) CreateMojangUuidsRepository() (queue.UuidsStorage, error) {
return f.createInstance()
}
func (f *RedisFactory) createInstance() (*redisDb, error) {
p, err := f.getPool()
if err != nil {
return nil, err
}
return &redisDb{connection}, nil
return &redisDb{p}, nil
}
func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
panic("capes repository not supported for this storage type")
}
func (f RedisFactory) getConnection() (*pool.Pool, error) {
if f.connection == nil {
func (f *RedisFactory) getPool() (*pool.Pool, error) {
if f.pool == nil {
if f.Host == "" {
return nil, &ParamRequired{"host"}
}
@@ -56,63 +63,87 @@ func (f RedisFactory) getConnection() (*pool.Pool, error) {
return nil, err
}
f.connection = conn
go func() {
period := 5
for {
time.Sleep(time.Duration(period) * time.Second)
resp := f.connection.Cmd("PING")
if resp.Err == nil {
continue
}
log.Println("Redis not pinged. Try to reconnect")
conn, err := pool.New("tcp", addr, f.PoolSize)
if err != nil {
log.Printf("Cannot reconnect to redis: %v\n", err)
log.Printf("Waiting %d seconds to retry\n", period)
continue
}
f.connection = conn
log.Println("Reconnected")
}
}()
f.pool = conn
}
return f.connection, nil
return f.pool, nil
}
type redisDb struct {
conn *pool.Pool
pool *pool.Pool
}
const accountIdToUsernameKey = "hash:username-to-account-id"
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
return findByUsername(username, db.getConn())
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
return findByUsername(username, conn)
}
func (db *redisDb) FindByUserId(id int) (*model.Skin, error) {
return findByUserId(id, db.getConn())
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
return findByUserId(id, conn)
}
func (db *redisDb) Save(skin *model.Skin) error {
return save(skin, db.getConn())
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return save(skin, conn)
}
func (db *redisDb) RemoveByUserId(id int) error {
return removeByUserId(id, db.getConn())
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUserId(id, conn)
}
func (db *redisDb) RemoveByUsername(username string) error {
return removeByUsername(username, db.getConn())
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUsername(username, conn)
}
func (db *redisDb) getConn() util.Cmder {
conn, _ := db.conn.Get()
return conn
func (db *redisDb) GetUuid(username string) (string, error) {
conn, err := db.pool.Get()
if err != nil {
return "", err
}
defer db.pool.Put(conn)
return findMojangUuidByUsername(username, conn)
}
func (db *redisDb) StoreUuid(username string, uuid string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return storeMojangUuid(username, uuid, conn)
}
func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
@@ -122,7 +153,7 @@ func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
redisKey := buildUsernameKey(username)
response := conn.Cmd("GET", redisKey)
if response.IsType(redis.Nil) {
if !response.IsType(redis.Str) {
return nil, &SkinNotFoundError{username}
}
@@ -149,7 +180,7 @@ func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
response := conn.Cmd("HGET", accountIdToUsernameKey, id)
if response.IsType(redis.Nil) {
if !response.IsType(redis.Str) {
return nil, &SkinNotFoundError{"unknown"}
}
@@ -181,17 +212,17 @@ func removeByUserId(id int, conn util.Cmder) error {
func removeByUsername(username string, conn util.Cmder) error {
record, err := findByUsername(username, conn)
if err != nil {
if _, ok := err.(*SkinNotFoundError); !ok {
return err
if _, ok := err.(*SkinNotFoundError); ok {
return nil
}
return err
}
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))
if record != nil {
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
}
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
conn.Cmd("EXEC")
@@ -221,6 +252,33 @@ func save(skin *model.Skin, conn util.Cmder) error {
return nil
}
func findMojangUuidByUsername(username string, conn util.Cmder) (string, error) {
response := conn.Cmd("HGET", mojangUsernameToUuidKey, strings.ToLower(username))
if response.IsType(redis.Nil) {
return "", &queue.ValueNotFound{}
}
data, _ := response.Str()
parts := strings.Split(data, ":")
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(time.Now()) {
return "", &queue.ValueNotFound{}
}
return parts[0], nil
}
func storeMojangUuid(username string, uuid string, conn util.Cmder) error {
value := uuid + ":" + strconv.FormatInt(time.Now().Unix(), 10)
res := conn.Cmd("HSET", mojangUsernameToUuidKey, strings.ToLower(username), value)
if res.IsType(redis.Err) {
return res.Err
}
return nil
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}
@@ -228,8 +286,8 @@ func buildUsernameKey(username string) string {
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
writer.Write(str)
writer.Close()
_, _ = writer.Write(str)
_ = writer.Close()
return buff.Bytes()
}
@@ -242,7 +300,7 @@ func zlibDecode(bts []byte) ([]byte, error) {
}
resultBuffer := new(bytes.Buffer)
io.Copy(resultBuffer, reader)
_, _ = io.Copy(resultBuffer, reader)
reader.Close()
return resultBuffer.Bytes(), nil

View File

@@ -20,6 +20,7 @@ import (
//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() {
@@ -71,7 +72,6 @@ func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
record.Uuid = req.Form.Get("uuid")
record.SkinId = skinId
record.Hash = req.Form.Get("hash")
record.Is1_8 = is18
record.IsSlim = isSlim
record.Url = req.Form.Get("url")
@@ -115,7 +115,7 @@ func (cfg *Config) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Requ
cfg.deleteSkin(skin, resp)
}
func (cfg *Config) Authenticate(handler http.Handler) http.Handler {
func (cfg *Config) AuthenticationMiddleware(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("authentication.challenge", 1)
err := cfg.Auth.Check(req)
@@ -152,7 +152,7 @@ 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.ParseMultipartForm(maxMultipartMemory)
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
@@ -161,7 +161,6 @@ func validatePostSkinRequest(request *http.Request) map[string][]string {
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"hash": {},
"is1_8": {"bool"},
"isSlim": {"bool"},
}
@@ -174,7 +173,6 @@ func validatePostSkinRequest(request *http.Request) map[string][]string {
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
validationRules["hash"] = append(validationRules["hash"], "required")
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
@@ -213,7 +211,7 @@ func findIdentity(repo interfaces.SkinsRepository, identityId int, username stri
record, err = repo.FindByUsername(username)
if err == nil {
repo.RemoveByUsername(username)
_ = repo.RemoveByUsername(username)
record.UserId = identityId
} else {
record = &model.Skin{
@@ -222,7 +220,7 @@ func findIdentity(repo interfaces.SkinsRepository, identityId int, username stri
}
}
} else if record.Username != username {
repo.RemoveByUserId(identityId)
_ = repo.RemoveByUserId(identityId)
record.Username = username
}
@@ -235,7 +233,7 @@ func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string)
result, _ := json.Marshal(map[string]interface{}{
"errors": errorsPerField,
})
resp.Write(result)
_, _ = resp.Write(result)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
@@ -244,7 +242,7 @@ func apiForbidden(resp http.ResponseWriter, reason string) {
result, _ := json.Marshal(map[string]interface{}{
"error": reason,
})
resp.Write(result)
_, _ = resp.Write(result)
}
func apiNotFound(resp http.ResponseWriter, reason string) {
@@ -253,7 +251,7 @@ func apiNotFound(resp http.ResponseWriter, reason string) {
result, _ := json.Marshal([]interface{}{
reason,
})
resp.Write(result)
_, _ = resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {

View File

@@ -17,474 +17,474 @@ import (
testify "github.com/stretchr/testify/assert"
)
func TestConfig_PostSkin_Valid(t *testing.T) {
assert := testify.New(t)
func TestConfig_PostSkin(t *testing.T) {
t.Run("Upload new identity with textures info", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Url = "http://example.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
form := url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_ChangedIdentityId(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.UserId = 2
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"2"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_ChangedUsername(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("changed_username", false)
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_CompletelyNewIdentity(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{"unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{"mock_user"})
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_UploadSkin(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("skin", "char.png")
part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", body)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
form := url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}
}`, string(response))
}
func TestConfig_PostSkin_RequiredFields(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{Who: "unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{Who: "mock_user"})
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config, mocks := setupMocks(ctrl)
config.CreateHandler().ServeHTTP(w, req)
form := url.Values{
"mojangTextures": {"someBase64EncodedString"},
}
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.Run("Upload new identity with skin file", func(t *testing.T) {
assert := testify.New(t)
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config.CreateHandler().ServeHTTP(w, req)
config, mocks := setupMocks(ctrl)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
"The identityId field must be numeric",
"The identityId field must be minimum 1 char"
],
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
],
"username": [
"The username field is required"
],
"uuid": [
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
}`, string(response))
req := httptest.NewRequest("POST", "http://chrly/api/skins", body)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(response))
})
t.Run("Keep the same identityId, uuid and username, but change textures information", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Url = "http://textures-server.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
form := url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://textures-server.com/skin.png"},
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Keep the same uuid and username, but change identityId", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.UserId = 2
resultModel.SkinId = 5
resultModel.Url = "http://example.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"2"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{Who: "unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Keep the same identityId and uuid, but change username", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("changed_username", false)
resultModel.SkinId = 5
resultModel.Url = "http://example.com/skin.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Get errors about required fields", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
form := url.Values{
"mojangTextures": {"someBase64EncodedString"},
}
req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
"The identityId field must be numeric",
"The identityId field must be minimum 1 char"
],
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
],
"username": [
"The username field is required"
],
"uuid": [
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
}
}`, string(response))
})
t.Run("Perform request without authorization", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://chrly/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{Reason: "Cannot parse passed JWT token"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "Cannot parse passed JWT token"
}`, string(response))
})
}
func TestConfig_PostSkin_Unauthorized(t *testing.T) {
assert := testify.New(t)
func TestConfig_DeleteSkinByUserId(t *testing.T) {
t.Run("Delete skin by its identity id", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
w := httptest.NewRecorder()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:1", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"Cannot parse passed JWT token"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "Cannot parse passed JWT token"
}`, string(response))
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
t.Run("Try to remove not exists identity id", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/id:2", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{Who: "unknown"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested user id"
]`, string(response))
})
}
func TestConfig_DeleteSkinByUserId_Success(t *testing.T) {
assert := testify.New(t)
func TestConfig_DeleteSkinByUsername(t *testing.T) {
t.Run("Delete skin by its identity username", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:1", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_user", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
})
func TestConfig_DeleteSkinByUserId_NotFound(t *testing.T) {
assert := testify.New(t)
t.Run("Try to remove not exists identity username", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:2", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("DELETE", "http://chrly/api/skins/mock_user_2", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user_2").Return(nil, &db.SkinNotFoundError{Who: "mock_user_2"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested user id"
]`, string(response))
}
func TestConfig_DeleteSkinByUsername_Success(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_DeleteSkinByUsername_NotFound(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user_2", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user_2").Return(nil, &db.SkinNotFoundError{"mock_user_2"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested username"
]`, string(response))
})
}
func TestConfig_Authenticate_SignatureKeyNotSet(t *testing.T) {
assert := testify.New(t)
func TestConfig_Authenticate(t *testing.T) {
t.Run("Test behavior when signing key is not set", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://localhost", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "http://localhost", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"signing key not available"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{Reason: "signing key not available"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
res := config.Authenticate(http.HandlerFunc(func (resp http.ResponseWriter, req *http.Request) {}))
res.ServeHTTP(w, req)
res := config.AuthenticationMiddleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {}))
res.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "signing key not available"
}`, string(response))
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "signing key not available"
}`, string(response))
})
}
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png

View File

@@ -14,13 +14,26 @@ func (cfg *Config) Cape(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := cfg.CapesRepo.FindByUsername(username)
if err != nil {
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
if err == nil {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, rec.File)
return
}
request.Header.Set("Content-Type", "image/png")
io.Copy(response, rec.File)
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
if mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, cape.Url, 301)
}
func (cfg *Config) CapeGET(response http.ResponseWriter, request *http.Request) {

View File

@@ -5,6 +5,7 @@ import (
"image"
"image/png"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
@@ -15,123 +16,147 @@ import (
"github.com/elyby/chrly/model"
)
type capesTestCase struct {
Name string
RequestUrl string
ExpectedLogKey string
ExistsInLocalStorage bool
ExistsInMojang bool
HasCapeInMojangResp bool
AssertResponse func(assert *testify.Assertions, resp *http.Response)
}
var capesTestCases = []*capesTestCase{
{
Name: "Obtain cape for known username",
ExistsInLocalStorage: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
assert.Equal(createCape(), responseData)
assert.Equal("image/png", resp.Header.Get("Content-Type"))
},
},
{
Name: "Obtain cape for unknown username that exists in Mojang and has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasCapeInMojangResp: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(301, resp.StatusCode)
assert.Equal("http://mojang/cape.png", resp.Header.Get("Location"))
},
},
{
Name: "Obtain cape for unknown username that exists in Mojang, but don't has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasCapeInMojangResp: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
{
Name: "Obtain cape for unknown username that doesn't exists in Mojang",
ExistsInLocalStorage: false,
ExistsInMojang: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
}
func TestConfig_Cape(t *testing.T) {
assert := testify.New(t)
performTest := func(t *testing.T, testCase *capesTestCase) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
cape := createCape()
mocks.Log.EXPECT().IncCounter(testCase.ExpectedLogKey, int64(1))
if testCase.ExistsInLocalStorage {
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(&model.Cape{
File: bytes.NewReader(createCape()),
}, nil)
} else {
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{Who: "mock_username"})
}
mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{
File: bytes.NewReader(cape),
}, nil)
mocks.Log.EXPECT().IncCounter("capes.request", int64(1))
if testCase.ExistsInMojang {
textures := createTexturesResponse(false, testCase.HasCapeInMojangResp)
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(textures)
} else {
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(nil)
}
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/mocked_username", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", testCase.RequestUrl, nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
assert.Equal(cape, responseData)
assert.Equal("image/png", resp.Header.Get("Content-Type"))
}
resp := w.Result()
testCase.AssertResponse(assert, resp)
}
func TestConfig_Cape2(t *testing.T) {
assert := testify.New(t)
t.Run("Normal API", func(t *testing.T) {
for _, testCase := range capesTestCases {
testCase.RequestUrl = "http://chrly/cloaks/mock_username"
testCase.ExpectedLogKey = "capes.request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
})
ctrl := gomock.NewController(t)
defer ctrl.Finish()
t.Run("GET fallback API", func(t *testing.T) {
for _, testCase := range capesTestCases {
testCase.RequestUrl = "http://chrly/cloaks?name=mock_username"
testCase.ExpectedLogKey = "capes.get_request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
config, mocks := setupMocks(ctrl)
t.Run("Should trim trailing slash", func(t *testing.T) {
assert := testify.New(t)
mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("capes.request", int64(1))
req := httptest.NewRequest("GET", "http://chrly/cloaks/?name=notch", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://chrly/cloaks?name=notch", resp.Header.Get("Location"))
})
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location"))
}
t.Run("Return error when name is not provided", func(t *testing.T) {
assert := testify.New(t)
func TestConfig_CapeGET(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1))
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
cape := createCape()
config.CreateHandler().ServeHTTP(w, req)
mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{
File: bytes.NewReader(cape),
}, nil)
mocks.Log.EXPECT().IncCounter("capes.request", int64(1)).Times(0)
mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=mocked_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
assert.Equal(cape, responseData)
assert.Equal("image/png", resp.Header.Get("Content-Type"))
}
func TestConfig_CapeGET2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("capes.request", int64(1)).Times(0)
mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location"))
}
func TestConfig_CapeGET3(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/?name=notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skinsystem.ely.by/cloaks?name=notch", resp.Header.Get("Location"))
resp := w.Result()
assert.Equal(400, resp.StatusCode)
})
})
}
// Cape md5: 424ff79dce9940af89c28ad80de8aaad
func createCape() []byte {
img := image.NewAlpha(image.Rect(0, 0, 64, 32))
writer := &bytes.Buffer{}
png.Encode(writer, img)
_ = png.Encode(writer, img)
pngBytes, _ := ioutil.ReadAll(writer)
return pngBytes

View File

@@ -19,10 +19,11 @@ import (
type Config struct {
ListenSpec string
SkinsRepo interfaces.SkinsRepository
CapesRepo interfaces.CapesRepository
Logger wd.Watchdog
Auth interfaces.AuthChecker
SkinsRepo interfaces.SkinsRepository
CapesRepo interfaces.CapesRepository
MojangTexturesQueue interfaces.MojangTexturesQueue
Logger wd.Watchdog
Auth interfaces.AuthChecker
}
func (cfg *Config) Run() error {
@@ -59,9 +60,11 @@ func (cfg *Config) CreateHandler() http.Handler {
router.HandleFunc("/skins", cfg.SkinGET).Methods("GET")
router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET")
// API
router.Handle("/api/skins", cfg.Authenticate(http.HandlerFunc(cfg.PostSkin))).Methods("POST")
router.Handle("/api/skins/id:{id:[0-9]+}", cfg.Authenticate(http.HandlerFunc(cfg.DeleteSkinByUserId))).Methods("DELETE")
router.Handle("/api/skins/{username}", cfg.Authenticate(http.HandlerFunc(cfg.DeleteSkinByUsername))).Methods("DELETE")
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.Use(cfg.AuthenticationMiddleware)
apiRouter.Handle("/skins", http.HandlerFunc(cfg.PostSkin)).Methods("POST")
apiRouter.Handle("/skins/id:{id:[0-9]+}", http.HandlerFunc(cfg.DeleteSkinByUserId)).Methods("DELETE")
apiRouter.Handle("/skins/{username}", http.HandlerFunc(cfg.DeleteSkinByUsername)).Methods("DELETE")
// 404
router.NotFoundHandler = http.HandlerFunc(cfg.NotFound)

View File

@@ -2,7 +2,11 @@ package http
import (
"testing"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/tests"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
@@ -19,6 +23,7 @@ func TestParseUsername(t *testing.T) {
type mocks struct {
Skins *mock_interfaces.MockSkinsRepository
Capes *mock_interfaces.MockCapesRepository
Queue *tests.MojangTexturesQueueMock
Auth *mock_interfaces.MockAuthChecker
Log *mock_wd.MockWatchdog
}
@@ -31,16 +36,54 @@ func setupMocks(ctrl *gomock.Controller) (
capesRepo := mock_interfaces.NewMockCapesRepository(ctrl)
authChecker := mock_interfaces.NewMockAuthChecker(ctrl)
wd := mock_wd.NewMockWatchdog(ctrl)
texturesQueue := &tests.MojangTexturesQueueMock{}
return &Config{
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Auth: authChecker,
Logger: wd,
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Auth: authChecker,
MojangTexturesQueue: texturesQueue,
Logger: wd,
}, &mocks{
Skins: skinsRepo,
Capes: capesRepo,
Auth: authChecker,
Queue: texturesQueue,
Log: wd,
}
}
func createTexturesResponse(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse {
timeZone, _ := time.LoadLocation("Europe/Minsk")
textures := &mojang.TexturesProp{
Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(),
ProfileID: "00000000000000000000000000000000",
ProfileName: "mock_user",
Textures: &mojang.TexturesResponse{},
}
if includeSkin {
textures.Textures.Skin = &mojang.SkinTexturesResponse{
Url: "http://mojang/skin.png",
}
}
if includeCape {
textures.Textures.Cape = &mojang.CapeTexturesResponse{
Url: "http://mojang/cape.png",
}
}
response := &mojang.SignedTexturesResponse{
Id: "00000000000000000000000000000000",
Name: "mock_user",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
},
},
}
return response
}

View File

@@ -6,47 +6,44 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
)
type signedTexturesResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []property `json:"properties"`
}
type property struct {
Name string `json:"name"`
Signature string `json:"signature,omitempty"`
Value string `json:"value"`
}
func (cfg *Config) SignedTextures(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("signed_textures.request", 1)
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
rec, err := cfg.SkinsRepo.FindByUsername(username)
if err != nil || rec.SkinId == 0 || rec.MojangTextures == "" {
if err == nil && rec.SkinId != 0 && rec.MojangTextures != "" {
responseData = &mojang.SignedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: rec.MojangSignature,
Value: rec.MojangTextures,
},
},
}
} else if request.URL.Query().Get("proxy") != "" {
responseData = <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
}
if responseData == nil {
response.WriteHeader(http.StatusNoContent)
return
}
responseData:= signedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
Props: []property{
{
Name: "textures",
Signature: rec.MojangSignature,
Value: rec.MojangTextures,
},
{
Name: "chrly",
Value: "how do you tame a horse in Minecraft?",
},
},
}
responseData.Props = append(responseData.Props, &mojang.Property{
Name: "chrly",
Value: "how do you tame a horse in Minecraft?",
})
responseJson,_ := json.Marshal(responseData)
responseJson, _ := json.Marshal(responseData)
response.Header().Set("Content-Type", "application/json")
response.Write(responseJson)
}

View File

@@ -12,60 +12,130 @@ import (
)
func TestConfig_SignedTextures(t *testing.T) {
assert := testify.New(t)
t.Run("Obtain signed textures for exists user", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_user",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}`, string(response))
}
func TestConfig_SignedTextures2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{})
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_user",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}`, string(response))
})
t.Run("Obtain signed textures for not exists user", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{})
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
})
t.Run("Obtain signed textures for exists user, but without signed textures", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
skinModel := createSkinModel("mock_user", false)
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(skinModel, nil)
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
})
t.Run("Obtain signed textures for exists user, but without signed textures", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
skinModel := createSkinModel("mock_user", false)
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(skinModel, nil)
mocks.Queue.On("GetTexturesForUsername", "mock_user").Once().Return(createTexturesResponse(true, false))
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user?proxy=true", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "00000000000000000000000000000000",
"name": "mock_user",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXIiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9tb2phbmcvc2tpbi5wbmcifX19"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}`, string(response))
})
}

View File

@@ -13,12 +13,25 @@ func (cfg *Config) Skin(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := cfg.SkinsRepo.FindByUsername(username)
if err != nil || rec.SkinId == 0 {
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301)
if err == nil && rec.SkinId != 0 {
http.Redirect(response, request, rec.Url, 301)
return
}
http.Redirect(response, request, rec.Url, 301)
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
if mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
skin := texturesProp.Textures.Skin
if skin == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, skin.Url, 301)
}
func (cfg *Config) SkinGET(response http.ResponseWriter, request *http.Request) {

View File

@@ -1,6 +1,7 @@
package http
import (
"net/http"
"net/http/httptest"
"testing"
@@ -11,113 +12,145 @@ import (
"github.com/elyby/chrly/model"
)
type skinsTestCase struct {
Name string
RequestUrl string
ExpectedLogKey string
ExistsInLocalStorage bool
ExistsInMojang bool
HasSkinInMojangResp bool
AssertResponse func(assert *testify.Assertions, resp *http.Response)
}
var skinsTestCases = []*skinsTestCase{
{
Name: "Obtain skin for known username",
ExistsInLocalStorage: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(301, resp.StatusCode)
assert.Equal("http://chrly/skin.png", resp.Header.Get("Location"))
},
},
{
Name: "Obtain skin for unknown username that exists in Mojang and has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasSkinInMojangResp: true,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(301, resp.StatusCode)
assert.Equal("http://mojang/skin.png", resp.Header.Get("Location"))
},
},
{
Name: "Obtain skin for unknown username that exists in Mojang, but don't has a cape",
ExistsInLocalStorage: false,
ExistsInMojang: true,
HasSkinInMojangResp: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
{
Name: "Obtain skin for unknown username that doesn't exists in Mojang",
ExistsInLocalStorage: false,
ExistsInMojang: false,
AssertResponse: func(assert *testify.Assertions, resp *http.Response) {
assert.Equal(404, resp.StatusCode)
},
},
}
func TestConfig_Skin(t *testing.T) {
assert := testify.New(t)
performTest := func(t *testing.T, testCase *skinsTestCase) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Log.EXPECT().IncCounter("skins.request", int64(1))
mocks.Log.EXPECT().IncCounter(testCase.ExpectedLogKey, int64(1))
if testCase.ExistsInLocalStorage {
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(createSkinModel("mock_username", false), nil)
} else {
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{Who: "mock_username"})
}
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user", nil)
w := httptest.NewRecorder()
if testCase.ExistsInMojang {
textures := createTexturesResponse(testCase.HasSkinInMojangResp, true)
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(textures)
} else {
mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(nil)
}
config.CreateHandler().ServeHTTP(w, req)
req := httptest.NewRequest("GET", testCase.RequestUrl, nil)
w := httptest.NewRecorder()
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location"))
}
config.CreateHandler().ServeHTTP(w, req)
func TestConfig_Skin2(t *testing.T) {
assert := testify.New(t)
resp := w.Result()
testCase.AssertResponse(assert, resp)
}
ctrl := gomock.NewController(t)
defer ctrl.Finish()
t.Run("Normal API", func(t *testing.T) {
for _, testCase := range skinsTestCases {
testCase.RequestUrl = "http://chrly/skins/mock_username"
testCase.ExpectedLogKey = "skins.request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
})
config, mocks := setupMocks(ctrl)
t.Run("GET fallback API", func(t *testing.T) {
for _, testCase := range skinsTestCases {
testCase.RequestUrl = "http://chrly/skins?name=mock_username"
testCase.ExpectedLogKey = "skins.get_request"
t.Run(testCase.Name, func(t *testing.T) {
performTest(t, testCase)
})
}
mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("skins.request", int64(1))
t.Run("Should trim trailing slash", func(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/notch", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://chrly/skins/?name=notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location"))
}
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://chrly/skins?name=notch", resp.Header.Get("Location"))
})
func TestConfig_SkinGET(t *testing.T) {
assert := testify.New(t)
t.Run("Return error when name is not provided", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1))
mocks.Log.EXPECT().IncCounter("skins.request", int64(1)).Times(0)
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location"))
}
func TestConfig_SkinGET2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1))
mocks.Log.EXPECT().IncCounter("skins.request", int64(1)).Times(0)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location"))
}
func TestConfig_SkinGET3(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/?name=notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skinsystem.ely.by/skins?name=notch", resp.Header.Get("Location"))
resp := w.Result()
assert.Equal(400, resp.StatusCode)
})
})
}
func createSkinModel(username string, isSlim bool) *model.Skin {
return &model.Skin{
UserId: 1,
Username: username,
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", // Use non nil UUID to pass validation in api tests
SkinId: 1,
Hash: "55d2a8848764f5ff04012cdb093458bd",
Url: "http://ely.by/minecraft/skins/skin.png",
Url: "http://chrly/skin.png",
MojangTextures: "mocked textures base64",
MojangSignature: "mocked signature",
IsSlim: isSlim,

View File

@@ -1,102 +1,61 @@
package http
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/api/mojang"
)
type texturesResponse struct {
Skin *Skin `json:"SKIN"`
Cape *Cape `json:"CAPE,omitempty"`
}
type Skin struct {
Url string `json:"url"`
Hash string `json:"hash"`
Metadata *skinMetadata `json:"metadata,omitempty"`
}
type skinMetadata struct {
Model string `json:"model"`
}
type Cape struct {
Url string `json:"url"`
Hash string `json:"hash"`
}
func (cfg *Config) Textures(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("textures.request", 1)
username := parseUsername(mux.Vars(request)["username"])
skin, err := cfg.SkinsRepo.FindByUsername(username)
if err != nil || skin.SkinId == 0 {
if skin == nil {
skin = &model.Skin{}
var textures *mojang.TexturesResponse
skin, skinErr := cfg.SkinsRepo.FindByUsername(username)
_, capeErr := cfg.CapesRepo.FindByUsername(username)
if (skinErr == nil && skin.SkinId != 0) || capeErr == nil {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
skinTextures.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
textures.Skin = skinTextures
}
skin.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png"
skin.Hash = string(buildNonElyTexturesHash(username))
}
textures := texturesResponse{
Skin: &Skin{
Url: skin.Url,
Hash: skin.Hash,
},
}
if skin.IsSlim {
textures.Skin.Metadata = &skinMetadata{
Model: "slim",
if capeErr == nil {
textures.Cape = &mojang.CapeTexturesResponse{
Url: request.URL.Scheme + "://" + request.Host + "/cloaks/" + username,
}
}
}
cape, err := cfg.CapesRepo.FindByUsername(username)
if err == nil {
var scheme string = "http://"
if request.TLS != nil {
scheme = "https://"
} else {
mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username)
if mojangTextures == nil {
response.WriteHeader(http.StatusNoContent)
return
}
textures.Cape = &Cape{
Url: scheme + request.Host + "/cloaks/" + username,
Hash: calculateCapeHash(cape),
texturesProp := mojangTextures.DecodeTextures()
if texturesProp == nil {
response.WriteHeader(http.StatusInternalServerError)
cfg.Logger.Error("Unable to find textures property")
return
}
textures = texturesProp.Textures
}
responseData, _ := json.Marshal(textures)
response.Header().Set("Content-Type", "application/json")
response.Write(responseData)
}
func calculateCapeHash(cape *model.Cape) string {
hasher := md5.New()
io.Copy(hasher, cape.File)
return hex.EncodeToString(hasher.Sum(nil))
}
func buildNonElyTexturesHash(username string) string {
hour := getCurrentHour()
hasher := md5.New()
hasher.Write([]byte("non-ely-" + strconv.FormatInt(hour, 10) + "-" + username))
return hex.EncodeToString(hasher.Sum(nil))
}
var timeNow = time.Now
func getCurrentHour() int64 {
n := timeNow()
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), 0, 0, 0, time.UTC).Unix()
}

View File

@@ -5,7 +5,6 @@ import (
"io/ioutil"
"net/http/httptest"
"testing"
"time"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
@@ -15,152 +14,181 @@ import (
)
func TestConfig_Textures(t *testing.T) {
assert := testify.New(t)
t.Run("Obtain textures for exists user with only default skin", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"})
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil)
w := httptest.NewRecorder()
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{Who: "mock_user"})
config.CreateHandler().ServeHTTP(w, req)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://ely.by/minecraft/skins/skin.png",
"hash": "55d2a8848764f5ff04012cdb093458bd"
}
}`, string(response))
}
config.CreateHandler().ServeHTTP(w, req)
func TestConfig_Textures2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"})
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://ely.by/minecraft/skins/skin.png",
"hash": "55d2a8848764f5ff04012cdb093458bd",
"metadata": {
"model": "slim"
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
}
}
}`, string(response))
}
func TestConfig_Textures3(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{
File: bytes.NewReader(createCape()),
}, nil)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://ely.by/minecraft/skins/skin.png",
"hash": "55d2a8848764f5ff04012cdb093458bd"
},
"CAPE": {
"url": "http://skinsystem.ely.by/cloaks/mock_user",
"hash": "424ff79dce9940af89c28ad80de8aaad"
}
}`, string(response))
}
func TestConfig_Textures4(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{})
mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{})
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
timeNow = func() time.Time {
return time.Date(2017, time.August, 20, 0, 15, 54, 0, time.UTC)
}
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://skins.minecraft.net/MinecraftSkins/notch.png",
"hash": "5923cf3f7fa170a279e4d7a9483cfc52"
}
}`, string(response))
}
func TestBuildNonElyTexturesHash(t *testing.T) {
assert := testify.New(t)
timeNow = func() time.Time {
return time.Date(2017, time.November, 30, 16, 15, 34, 0, time.UTC)
}
assert.Equal("686d788a5353cb636e8fdff727634d88", buildNonElyTexturesHash("username"), "Function should return fixed hash by username-time pair")
assert.Equal("fb876f761683a10accdb17d403cef64c", buildNonElyTexturesHash("another-username"), "Function should return fixed hash by username-time pair")
timeNow = func() time.Time {
return time.Date(2017, time.November, 30, 16, 20, 12, 0, time.UTC)
}
assert.Equal("686d788a5353cb636e8fdff727634d88", buildNonElyTexturesHash("username"), "Function should do not change it's value if hour the same")
assert.Equal("fb876f761683a10accdb17d403cef64c", buildNonElyTexturesHash("another-username"), "Function should return fixed hash by username-time pair")
timeNow = func() time.Time {
return time.Date(2017, time.November, 30, 17, 1, 3, 0, time.UTC)
}
assert.Equal("42277892fd24bc0ed86285b3bb8b8fad", buildNonElyTexturesHash("username"), "Function should change it's value if hour changed")
}`, string(response))
})
t.Run("Obtain textures for exists user with only slim skin", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{Who: "mock_user"})
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png",
"metadata": {
"model": "slim"
}
}
}`, string(response))
})
t.Run("Obtain textures for exists user with only cape", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{Who: "mock_user"})
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{File: bytes.NewReader(createCape())}, nil)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_user"
}
}`, string(response))
})
t.Run("Obtain textures for exists user with skin and cape", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{File: bytes.NewReader(createCape())}, nil)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
},
"CAPE": {
"url": "http://chrly/cloaks/mock_user"
}
}`, string(response))
})
t.Run("Obtain textures for not exists user that exists in Mojang", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{})
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{})
mocks.Queue.On("GetTexturesForUsername", "mock_username").Once().Return(createTexturesResponse(true, true))
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"SKIN": {
"url": "http://mojang/skin.png"
},
"CAPE": {
"url": "http://mojang/cape.png"
}
}`, string(response))
})
t.Run("Obtain textures for not exists user that not exists in Mojang too", func(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Log.EXPECT().IncCounter("textures.request", int64(1))
mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{})
mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{})
mocks.Queue.On("GetTexturesForUsername", "mock_username").Once().Return(nil)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
})
}

View File

@@ -1,6 +1,7 @@
package interfaces
import (
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
@@ -15,3 +16,7 @@ type SkinsRepository interface {
type CapesRepository interface {
FindByUsername(username string) (*model.Cape, error)
}
type MojangTexturesQueue interface {
GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse
}

View File

@@ -8,7 +8,6 @@ type Skin struct {
Url string `json:"url"`
Is1_8 bool `json:"is1_8"`
IsSlim bool `json:"isSlim"`
Hash string `json:"hash"`
MojangTextures string `json:"mojangTextures"`
MojangSignature string `json:"mojangSignature"`
OldUsername string

View File

@@ -0,0 +1,33 @@
package tests
import (
"github.com/elyby/chrly/api/mojang"
"github.com/stretchr/testify/mock"
)
type MojangTexturesQueueMock struct {
mock.Mock
}
func (m *MojangTexturesQueueMock) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse {
args := m.Called(username)
result := make(chan *mojang.SignedTexturesResponse)
arg := args.Get(0)
switch arg.(type) {
case *mojang.SignedTexturesResponse:
go func() {
result <- arg.(*mojang.SignedTexturesResponse)
}()
case chan *mojang.SignedTexturesResponse:
return arg.(chan *mojang.SignedTexturesResponse)
case nil:
go func() {
result <- nil
}()
default:
panic("unsupported return value")
}
return result
}

61
tests/wd_mock.go Normal file
View File

@@ -0,0 +1,61 @@
package tests
import (
"time"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/stretchr/testify/mock"
)
type WdMock struct {
mock.Mock
}
func (m *WdMock) Trace(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Debug(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Info(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Warning(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Error(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Alert(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) Emergency(message string, p ...slf.Param) {
m.Called(message)
}
func (m *WdMock) IncCounter(name string, value int64, p ...slf.Param) {
m.Called(name, value)
}
func (m *WdMock) UpdateGauge(name string, value int64, p ...slf.Param) {
m.Called(name, value)
}
func (m *WdMock) RecordTimer(name string, d time.Duration, p ...slf.Param) {
m.Called(name, d)
}
func (m *WdMock) Timer(name string, p ...slf.Param) slf.Timer {
return slf.NewTimer(name, p, m)
}
func (m *WdMock) WithParams(p ...slf.Param) wd.Watchdog {
panic("this method shouldn't be used")
}