Compare commits

...

277 Commits

Author SHA1 Message Date
ErickSkrauch
e08bb23b3d Prepare 4.4.1 release 2020-04-24 01:02:38 +03:00
ErickSkrauch
2d555d9253 Resolves #20. Print Chrly's version during server startup. 2020-04-23 21:22:12 +03:00
ErickSkrauch
dbefac0e84 Resolves #21. Print hostname in the version command output 2020-04-23 21:21:03 +03:00
ErickSkrauch
15c6816813 Fixed condition when to publish the latest tag to docker hub 2020-04-23 21:00:19 +03:00
ErickSkrauch
bc2f9564d0 Resolves #22. Fix passing version to the binary 2020-04-23 20:43:36 +03:00
ErickSkrauch
fbbb96603c Prepare 4.4.0 release 2020-04-22 15:53:02 +03:00
ErickSkrauch
06b61e1603 Allow to skip deployment with [skip deploy] commit message part 2020-04-22 02:50:47 +03:00
ErickSkrauch
45a93deb24 Update changelog 2020-04-22 02:50:04 +03:00
ErickSkrauch
eec830a828 Apply possible at the moment fossa fixes 2020-04-22 02:44:11 +03:00
ErickSkrauch
7fb12f4a85 Try different project name for fossa 2020-04-21 16:42:09 +03:00
ErickSkrauch
7978462540 Enable FOSSA back 2020-04-21 16:31:36 +03:00
ErickSkrauch
2df31704c1 Fix cape url scheme 2020-04-21 16:24:30 +03:00
ErickSkrauch
6453583e31 Merge branch 'db_rework' 2020-04-21 16:18:20 +03:00
ErickSkrauch
803f3f406b Add tests for filesystem driver 2020-04-21 16:17:52 +03:00
ErickSkrauch
6c59ecbe2e Add tests for redis db adapter 2020-04-21 02:20:45 +03:00
ErickSkrauch
a07905ca5a Rework db layer.
Add database checker.
Rename SkinsRepositoryInterface and CapesRepositoryInterface methods.
2020-04-20 22:18:27 +03:00
ErickSkrauch
632ad4795a Disable FOSSA 2020-04-20 20:12:44 +03:00
ErickSkrauch
4ff164fffd Don't return an empty object if Mojang's textures don't contain any skin or cape 2020-04-20 19:58:31 +03:00
ErickSkrauch
5862d1cbf6 Fix statsd initialization 2020-04-20 19:04:15 +03:00
ErickSkrauch
440b505306 Merge pull request #19 from elyby/di
DI and some refactorings
2020-04-20 18:44:09 +03:00
ErickSkrauch
a4cf29c797 Update README [skip ci] 2020-04-20 18:41:39 +03:00
ErickSkrauch
ced4171eef Update CHANGELOG 2020-04-20 17:22:19 +03:00
ErickSkrauch
e098b8d86f Fix tests 2020-04-20 17:22:04 +03:00
ErickSkrauch
bca1436baf Resolves #18. Log panics to the Sentry 2020-04-20 17:13:16 +03:00
ErickSkrauch
d9fbfe658a Cleanup TODO items 2020-04-20 15:40:20 +03:00
ErickSkrauch
0be85b356b Handling correctly closing the server 2020-04-20 15:34:52 +03:00
ErickSkrauch
cc4cd2874c Drop usage of the SkinNotFoundError and CapeNotFoundError
More accurate redis results checking
Return correct errors from filesystem db driver
2020-04-20 15:16:15 +03:00
ErickSkrauch
2ea4c55d37 Split Dispatcher interface and use it across application 2020-04-20 14:29:33 +03:00
ErickSkrauch
f58b980948 Simplify health checkers initialization 2020-04-20 13:58:53 +03:00
ErickSkrauch
3f81a0c18a Completely move app configuration from cmd to di container
Implemented graceful server shutdown
Extract records manipulating API into separate handlers group
2020-04-19 02:31:09 +03:00
ErickSkrauch
9046338396 Introduce di into the project 2020-04-16 19:42:38 +03:00
ErickSkrauch
0c81494559 Fix tests for worker 2020-04-15 17:52:13 +03:00
ErickSkrauch
c9f6079d90 Enable requests logging for the worker.
Code is still duplicated. Will search for a solution later.
2020-04-15 16:01:30 +03:00
ErickSkrauch
b0ba94751a Merge branch 'worker_health_status'. Resolves #16 2020-04-10 16:38:51 +03:00
ErickSkrauch
2a5be658d8 Implemented batch uuids provider queue length checker 2020-04-10 15:39:48 +03:00
ErickSkrauch
153efdcce6 Implemented error expiring for batch mojang uuids provider response checker 2020-04-10 15:19:10 +03:00
ErickSkrauch
677f48ff3f Simple implementation of the health checker for the worker 2020-04-10 03:07:53 +03:00
ErickSkrauch
db19fe62f2 Add mojang_textures:batch_uuids_provider:result event for the batch uuids provider 2020-04-10 02:47:53 +03:00
ErickSkrauch
f11dee57ff Fix passing emitter to the authentication service 2020-04-07 00:13:04 +03:00
ErickSkrauch
d526b74d07 Enable dependencies caching 2020-04-06 19:37:05 +03:00
ErickSkrauch
270e93d39e Squashed commit of the following:
commit 4994c48a75a8791e841710f5cf11d55a2eb6eac5
Author: ErickSkrauch <erickskrauch@yandex.ru>
Date:   Mon Apr 6 19:01:29 2020 +0300

    Move fossa run to the separate job

commit 61b797194a0641b126f6daf41d1e1e60fbe7bc8a
Author: ErickSkrauch <erickskrauch@yandex.ru>
Date:   Mon Apr 6 18:49:57 2020 +0300

    Update fossa integration

commit 06a8070df84da5122ec55b6b1450d5cd5cbb4cb8
Merge: 53296c7 d79e765
Author: ErickSkrauch <erickskrauch@yandex.ru>
Date:   Mon Apr 6 17:47:55 2020 +0300

    Merge branch 'master' of https://github.com/fossabot/chrly into fossabot-master

commit d79e765bb0
Author: fossabot <badges@fossa.io>
Date:   Sun Apr 5 07:31:43 2020 -0700

    Add license scan report and status

    Signed-off-by: fossabot <badges@fossa.io>
2020-04-06 19:12:10 +03:00
ErickSkrauch
53296c7015 Fix travis warnings 2020-04-06 17:32:10 +03:00
ErickSkrauch
092ea3d4e2 Resolves #13. Add a config param to completely disable Mojang textures provider 2020-04-06 17:16:11 +03:00
ErickSkrauch
03c5a03c73 Bump Go version to 1.14 2020-04-06 17:16:02 +03:00
ErickSkrauch
262babbeaa Merge pull request #17 from elyby/event_dispatcher_refactoring
Event dispatcher refactoring
2020-04-06 16:32:04 +03:00
ErickSkrauch
a459809b6b Update CHANGELOG 2020-04-06 16:30:55 +03:00
ErickSkrauch
2fbeb492f0 Initialize subscribers on app bootstrapping 2020-04-04 19:14:12 +03:00
ErickSkrauch
0546b0519b Do not send debug messages from logger event subscriber. Debug messages will be implemented later in a separate events' listener. 2020-04-04 00:36:56 +03:00
ErickSkrauch
767971a197 Call mojang_textures:after_result event before broadcasting results to listeners 2020-04-04 00:28:50 +03:00
ErickSkrauch
336fcdd072 Replace map with slice in stats_reporter test to avoid maps randomized order 2020-04-03 23:03:15 +03:00
ErickSkrauch
49a1aaada0 Cleanup: remove tests and scripts folders.
Introduce Subscriber interface to make eventsubscriber package independent of package dispatcher.
2020-04-03 22:20:56 +03:00
ErickSkrauch
bd13480175 Added uuid normalization for mojang's uuid to textures request 2020-04-03 20:23:34 +03:00
ErickSkrauch
20a8d90ad7 Add tests for requests logger 2020-04-03 17:18:02 +03:00
ErickSkrauch
532f2206da Add simple requests logger (in style of Apache Common log format).
Fix Gopkg.lock integrity error.
2020-04-02 19:34:39 +03:00
ErickSkrauch
280a55d553 Restored logger for mojang textures providers errors 2020-04-02 02:29:14 +03:00
ErickSkrauch
c5e92e7a02 Added missing tests 2020-03-31 03:53:46 +03:00
ErickSkrauch
880182ccbf Replace event dispatcher by the fork to allow emitting events with nil arguments 2020-03-30 15:44:12 +03:00
ErickSkrauch
e3b9e3c069 Add events handlers to reimplement all statsd metrics, available before refactoring.
Tests aren't working at this time :(
Removed mojang_textures.invalid_username metric.
2020-03-30 12:30:06 +03:00
ErickSkrauch
e1c30a0ba1 Generalize mojang textures events 2020-03-29 22:10:31 +03:00
ErickSkrauch
40c53ea0d9 Add stats reporter events listener, restore all events for http layer, rework authentication middleware and authenticator interface 2020-02-16 13:23:47 +03:00
ErickSkrauch
db728451f8 Implemented event dispatcher 2020-02-08 14:31:47 +03:00
ErickSkrauch
2abe2db469 Integrate event dispatcher into mojangtextures package 2020-02-08 14:27:58 +03:00
ErickSkrauch
b2ee10f72f Completely rework the HTTP app layer structure. Replace a logger with an event dispatcher. Adjust tests to the new app architecture. 2020-01-29 01:34:15 +03:00
ErickSkrauch
fbfe9f4516 Try to fix deploy stage condition 2020-01-06 00:28:31 +03:00
ErickSkrauch
57b7c59929 Make extra property in the signed textures response to be adjusted 2020-01-06 00:16:38 +03:00
ErickSkrauch
0f2b000d70 Update Go badge 2020-01-06 00:05:40 +03:00
ErickSkrauch
af49eef84c Merge pull request #12 from elyby/iss_11
Add remote uuids provider and worker mode
2020-01-05 23:47:24 +03:00
ErickSkrauch
92473d15d6 Replace simple Mutex with RWMutex for in memory textures storage 2020-01-05 23:45:11 +03:00
ErickSkrauch
bc1427dd1f Exclude deployment for pull requests, move docker and sudo requirements to deploy step 2020-01-05 23:35:38 +03:00
ErickSkrauch
a8e4f7ae56 Ugly and dirty solution to sync batch_uuids_provider_test 2020-01-05 23:25:17 +03:00
ErickSkrauch
17f82ec6d3 Resolve golangcibot issues 2020-01-05 20:39:17 +03:00
ErickSkrauch
9946eae73b Update docs 2020-01-05 20:19:41 +03:00
ErickSkrauch
a4a9201034 Add additional synchronization layer for bath_uuids_provider_test 2020-01-03 01:41:51 +03:00
ErickSkrauch
7f9b60ab3a Fix race condition error 2020-01-03 01:04:23 +03:00
ErickSkrauch
5a0c10c1a1 Implemented worker command 2020-01-03 00:51:57 +03:00
ErickSkrauch
1e91aef0a6 Rework http app structure, get rid of the golang/mock package, rewrite http tests 2020-01-01 23:42:45 +03:00
ErickSkrauch
1033069211 Implemented remote api mojang uuids provider 2019-11-24 04:29:22 +03:00
ErickSkrauch
d27caa4922 Add sync channel to batch_uuids_provider_test 2019-11-21 19:33:05 +03:00
ErickSkrauch
0644dfe021 Completely rework mojang textures queue implementation, split it across separate data providers 2019-11-21 02:18:36 +03:00
ErickSkrauch
6fd88e077e Prepare 4.3.0 release 2019-11-08 02:12:31 +03:00
ErickSkrauch
ae185e1daa Bump Go version to 1.13 2019-11-08 02:08:10 +03:00
ErickSkrauch
7353047467 Increase queue loop delay from 1 to 2.5 seconds. Add configuration param to adjust its value 2019-11-08 01:54:16 +03:00
ErickSkrauch
b2a1fd450b Handle 403 Forbidden error from Mojang's API 2019-11-08 01:32:26 +03:00
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
ErickSkrauch
b73582bbf4 Do not limit hash format only to md5 2018-03-19 02:16:07 +03:00
ErickSkrauch
34598e39bc Allow any uuid version 2018-03-19 01:16:37 +03:00
ErickSkrauch
9fc6ca54d9 Fix latest tag condition 2018-02-17 02:21:03 +03:00
ErickSkrauch
aed957a896 Fix latest tag condition 2018-02-17 02:15:20 +03:00
ErickSkrauch
2cd97dda8b Compile without CGO usage 2018-02-17 01:25:24 +03:00
ErickSkrauch
ded50df8b8 Build chrly statically to work with Alpine image 2018-02-17 01:14:43 +03:00
ErickSkrauch
d7bc77e5a7 Adjust Travis setup 2018-02-16 20:58:29 +03:00
ErickSkrauch
befa163f0e Merge branch 'develop' 2018-02-16 20:44:12 +03:00
ErickSkrauch
cb7adab3df Merge branch '4.1' into develop 2018-02-16 18:31:56 +03:00
ErickSkrauch
87a302c7da Hello, Travis? 2018-02-16 00:46:20 +03:00
ErickSkrauch
ce4dce49a2 Completely rename project to the Chrly and make it ready to be opensourced 2018-02-16 00:13:57 +03:00
ErickSkrauch
11647f2eae Remove gitlab-ci 2018-02-16 00:02:05 +03:00
ErickSkrauch
acd0237fac Update Dockerfile, add docker-compose for prod and dev environment, cleanup some old things 2018-02-16 00:01:46 +03:00
ErickSkrauch
55f52d0ad4 Add project README 2018-02-15 23:57:57 +03:00
ErickSkrauch
778bc615aa The configuration file was deleted in favor of using environment variables.
Token generation functionality remove. Secret token now provided via CHRLY_SECRET env variable.
2018-02-15 23:57:23 +03:00
ErickSkrauch
235f65f11c Add LICENSE 2018-02-15 01:03:40 +03:00
ErickSkrauch
8dd6a581a9 Fix commands descriptions 2018-02-14 23:49:22 +03:00
ErickSkrauch
055f3ce6c0 Rename ely field into chrly 2018-02-11 17:03:13 +03:00
ErickSkrauch
a9f5632743 Remove Ely.by documentation link from not found response 2018-02-11 16:57:20 +03:00
ErickSkrauch
ce99ac8cf8 Removed Ely-specific faces API 2018-02-01 23:16:52 +03:00
ErickSkrauch
6192a58f63 Removed buildUrl() helper for automatically adding ely.by domain to skin. Now it's universtal 2018-02-01 22:58:34 +03:00
ErickSkrauch
caebac1753 Added version print by --version flag 2018-01-24 23:36:42 +03:00
ErickSkrauch
dcaa4c037d Removed mentions of the AMQP worker and Accounts Ely.by internal API inside configs 2018-01-24 00:26:50 +03:00
ErickSkrauch
9e4f805ed3 Removed sentry logger implementation (we are already use accepter PR with this implementation) 2018-01-24 00:19:26 +03:00
ErickSkrauch
ad7faf6e81 Added statsd metrics logging for newly created API 2018-01-24 00:14:31 +03:00
ErickSkrauch
855302ec60 Removed amqp worker command implementation
Removed Accounts Ely.by api implementation
2018-01-23 23:49:50 +03:00
ErickSkrauch
f5f8fbc65e Added test for the case, when signing key is not available 2018-01-23 23:20:28 +03:00
ErickSkrauch
968c83db99 Implemented skin deleting 2018-01-23 22:58:42 +03:00
ErickSkrauch
1e2f30c6c7 Forgot to commit auth checker interface mock 2018-01-23 18:53:14 +03:00
ErickSkrauch
f120064fe3 Implemented API endpoint to update skin information
Added tests to jwt package
Reworked redis backend implementation
Skin repository now have methods to remove skins by user id or username
2018-01-23 18:43:37 +03:00
ErickSkrauch
aaff88d32f Reworked http tests mocking 2018-01-23 00:16:42 +03:00
ErickSkrauch
b8c3cc6cf8 Added sh script to rebuild mocks for interfaces 2018-01-20 21:23:05 +03:00
ErickSkrauch
ca4479252f Implemented jwt generation 2018-01-15 23:52:22 +03:00
ErickSkrauch
d2485df64d Use accepted PR of github.com/mono83/slf with support of Sentry logger 2018-01-05 00:10:38 +03:00
ErickSkrauch
6a489287ba Removed OldSkinId field 2018-01-04 23:47:05 +03:00
ErickSkrauch
6e7a61f5f2 Merge branch 'develop' 2017-09-29 14:01:27 +03:00
ErickSkrauch
20b8e8da86 Возвращаем ошибку, если не удалось раскодировать JSON с информацией о скине из Redis 2017-09-29 13:59:29 +03:00
ErickSkrauch
63df092973 Merge branch 'develop' 2017-09-11 17:00:22 +03:00
ErickSkrauch
378643623b Исправлена ошибка, которая возвращается, если в бд не найдено записи о скине 2017-09-11 16:49:08 +03:00
ErickSkrauch
e33b86b809 Merge branch 'develop' 2017-09-11 14:25:15 +03:00
ErickSkrauch
80fa307915 Обновлён .gitlab-ci: переименованы ENV перменные в соответствии с 9 версией GitLab
Исправлен вызов компилятора для "зашивания" версии при сборке
2017-09-11 14:17:28 +03:00
ErickSkrauch
2e9520db89 Добавлена команда version для отображения версии 2017-09-11 14:16:25 +03:00
ErickSkrauch
74564b4747 Fixes SKINSYSTEM-3 2017-09-11 13:54:11 +03:00
ErickSkrauch
18909776a8 Merge branch 'develop' 2017-09-05 01:05:17 +03:00
ErickSkrauch
d1b1f22a93 Merge branch 'v4' 2017-09-05 01:04:41 +03:00
ErickSkrauch
cb928a3918 Исправлен volume для worker в docker-compose под production [skip ci] 2017-09-05 00:57:40 +03:00
ErickSkrauch
d9aeaba627 Компилируем на golang:1.9-alpine image 2017-09-04 23:56:10 +03:00
ErickSkrauch
645f6ac694 Для сборки проекта теперь используется Go 1.9 2017-09-04 20:25:32 +03:00
ErickSkrauch
eab7c6ecaa Все Docker штуки опущены в директорию docker.
Production Docker контейнер теперь использует alpine linux вместо пустого scratch
В production Docker контейнер добавлен docker-entrypoint.sh, который автоматически создаёт конфиг по умолчанию.
2017-09-04 20:24:55 +03:00
ErickSkrauch
ac714de8df Логгер в консоль теперь не добавляет метку в конец строки, а также выводит время в более коротком формате 2017-09-03 22:54:46 +03:00
ErickSkrauch
8007b082d6 Реализовано автоматическое восстановление соединения с AMQP 2017-09-03 22:45:38 +03:00
ErickSkrauch
9cb6502f9c Модели amqp событий перенесены непосредственно в компонент amqp worker 2017-09-03 21:41:40 +03:00
ErickSkrauch
76a3f3ad26 rabbitmq images заменены на alpine версии 2017-09-03 21:28:17 +03:00
ErickSkrauch
bdd7c5e15e Обновлены docker-compose файлы
Добавлен config.dist.yml
Обновлено README проекта (наконец-то нормально описание!)
Файл конфигурации теперь автоматически ищется в директории проекта.
2017-09-03 00:09:11 +03:00
ErickSkrauch
340b24d862 Добавлена генерация версии при сборке проекта 2017-09-02 21:37:16 +03:00
ErickSkrauch
cf99a0eab2 Добавлена интеграция с Sentry 2017-08-27 18:10:03 +03:00
ErickSkrauch
fb4ae46e29 На этап сборки docker контейнера возвращено использование репозитория 2017-08-24 15:10:30 +03:00
ErickSkrauch
971155485b Игнорируем возможную неудачу команды docker rmi на этапе cleanup 2017-08-24 15:04:52 +03:00
ErickSkrauch
9ee3e93042 Обновлёна логика построения production image, используем только scratch, без alpine linux 2017-08-24 14:57:03 +03:00
ErickSkrauch
6128c56a0c Добавлен вызов runtime.GOMAXPROCS()
Обновлены зависимости
2017-08-23 00:01:58 +03:00
ErickSkrauch
a2e3d28580 Добавлены скрипты для тестирования и подсчёта общего coverage 2017-08-21 18:45:27 +03:00
ErickSkrauch
fecfa9c4e8 Оттестирован функционал пакета worker 2017-08-21 15:37:15 +03:00
ErickSkrauch
04714543b8 Реорганизация пакета daemon в http.
Упразднён пакет utils.
Удалён обработчик minecraft.php (legacy с самого-самого начала Ely.by)
Добавлены тесты для всех api-запросов.
2017-08-20 01:22:42 +03:00
ErickSkrauch
ec461efe34 Добавлена логика автоматического рефреша API токена при его истечении 2017-08-18 17:48:29 +03:00
ErickSkrauch
eec6b384b7 Тестирование включено в CI 2017-08-18 02:03:18 +03:00
ErickSkrauch
4734bfd93c Восстановлена логика для доступна к internal API Accounts Ely.by 2017-08-18 01:08:08 +03:00
ErickSkrauch
b1dbee2310 repositories package переименован в interfaces 2017-08-18 00:50:23 +03:00
ErickSkrauch
78917a70d3 Частично восстановлена логика AMQP воркера 2017-08-17 02:47:35 +03:00
ErickSkrauch
4bf146dd43 Восстановлен логгинг метрик в statsd, если таковой указан в конфигурации 2017-08-16 15:23:03 +03:00
ErickSkrauch
06b8e88346 Реализовано автоматическое восстановление соединения с redis 2017-08-15 01:03:02 +03:00
ErickSkrauch
4945b3f984 Исправлен Dockerfile 2017-08-15 00:44:27 +03:00
ErickSkrauch
359aef4b40 Миграция с glide на dep для управления зависимостями 2017-08-15 00:43:56 +03:00
ErickSkrauch
b159cd327c Подчищены команды в cmd 2017-08-15 00:43:31 +03:00
ErickSkrauch
b99697d26e Попытка сделать фабрики репозиториев для абстрактных хранилищ данных.
Добавлено чтение конфигурации из файла.
2017-08-14 21:06:22 +03:00
ErickSkrauch
d51c358ef6 Имплементации репозиториев теперь хранятся в том же пакете, что и базовое описание фабрики репозитория 2017-08-10 03:14:28 +03:00
ErickSkrauch
d9629b5e83 Возвращаем ошибки по ссылке в реализациях репозиториев 2017-08-10 03:00:02 +03:00
ErickSkrauch
428bedf301 Entities в model, repositories в repositories 2017-08-09 19:19:46 +03:00
ErickSkrauch
11a7570f51 Учитываем пустой input для методов FindByUsername 2017-08-09 19:11:53 +03:00
ErickSkrauch
676ba03c37 Применены рекомендации от index0h 2017-07-02 03:35:38 +03:00
ErickSkrauch
07903cf9c8 Переработка структуры проекта 2017-06-30 18:40:25 +03:00
ErickSkrauch
e090d04dc7 Go обновлён до 1.9
Перешли на использование менеджера зависимостей glide
2017-06-28 13:32:20 +03:00
ErickSkrauch
a993c1d157 Реализовано сжатие значений в redis 2017-06-19 02:20:38 +03:00
ErickSkrauch
a661f9aac3 Merge branch 'develop' 2017-06-17 01:30:58 +03:00
ErickSkrauch
9ffdf99b77 Образ redis заменён на 3.2-32bit для экономии памяти 2017-06-17 01:17:02 +03:00
ErickSkrauch
ad35872fc1 Добавлено логирование запроса получения токена авторизации 2017-06-17 01:16:34 +03:00
ErickSkrauch
a8d8fffaa5 Исправлен адрес для API запросов 2017-06-17 01:16:08 +03:00
ErickSkrauch
0d41f0c347 Merge branch 'develop' 2017-04-13 14:20:36 +03:00
ErickSkrauch
b22f0551fa Не добавляем домен к ссылке на скин, если скин уже имеет домен 2017-04-13 14:20:04 +03:00
ErickSkrauch
1a906cfc09 Merge branch 'develop' 2017-04-10 20:50:25 +03:00
ErickSkrauch
f610667aa5 Revert statsd config 2017-04-10 20:47:45 +03:00
ErickSkrauch
8b51c1bd0c Merge branch 'develop' 2017-04-10 20:38:35 +03:00
ErickSkrauch
cbe940f8ec Конфиг Accounts API вынесен в параметры окружения 2017-04-10 20:28:47 +03:00
ErickSkrauch
8693673a71 Добавлена поддержка восстановления информации об аккаунте, если по какой-то причине её не удалось найти в хранилище 2017-04-10 14:53:26 +03:00
ErickSkrauch
73205648d2 Merge branch 'develop' 2017-04-04 18:54:22 +03:00
ErickSkrauch
3d73cc9402 Добавлено Ely property 2017-04-01 12:46:25 +03:00
ErickSkrauch
39f5ec5bee Учитываем ситуацию, когда скин есть, а просчитанной текстуры - нет 2017-04-01 12:22:48 +03:00
ErickSkrauch
e652691b29 Добавлена поддержка отображения подписанных текстур 2017-04-01 12:18:57 +03:00
ErickSkrauch
d3b4bee3b0 Обновлён docker-composer.base.yml 2017-03-25 16:48:47 +03:00
ErickSkrauch
ae50e90ea7 Merge branch 'statsd' into develop 2017-03-25 16:38:09 +03:00
ErickSkrauch
c74151c558 Переименованы некоторые параметры для statsd 2017-03-25 16:36:37 +03:00
ErickSkrauch
445bd18fbc Merge branch 'develop' into statsd 2017-03-25 15:56:07 +03:00
ErickSkrauch
6a881a62e3 Доработана конфигурация взаимодействия со statsd 2017-03-25 15:55:57 +03:00
ErickSkrauch
201a257d69 Исправлены вызовы IncCounter для обработчиков задач из amqp 2017-03-25 15:54:17 +03:00
ErickSkrauch
5d46094643 Удалён обработчик для запроса setSkin 2016-12-03 02:08:00 +03:00
ErickSkrauch
1694403c79 Добавлен gitlab-ci 2016-12-03 02:05:23 +03:00
ErickSkrauch
66c61dc3cd Добавлено логгирование метрик для системы скинов 2016-12-03 01:57:55 +03:00
ErickSkrauch
a0d940f8cd Попытка внедрить statsd 2016-11-15 14:15:16 +03:00
ErickSkrauch
58a1c6ec33 Merge branch 'develop' 2016-11-15 13:42:30 +03:00
ErickSkrauch
34179ae1fe Исправлена опечатка в ссылке на лицо 2016-11-15 13:41:52 +03:00
ErickSkrauch
6a54af62aa Merge branch 'develop' 2016-11-02 16:55:49 +03:00
ErickSkrauch
e05c5f200c Поддержка env для подключения к внешним контейнерам 2016-10-12 20:43:10 +03:00
ErickSkrauch
9c4930a0be Используем alpine image 2016-10-11 18:20:17 +03:00
ErickSkrauch
aab7ba9517 Фикс группировки импортов 2016-10-11 18:01:33 +03:00
ErickSkrauch
dea674f52e Merge branch 'capes_support' into develop
# Conflicts:
#	docker-compose.base.yml
2016-10-11 17:56:53 +03:00
ErickSkrauch
e8a7008e11 Реорганизация контейнеров в compose файлах 2016-10-06 00:38:53 +03:00
ErickSkrauch
9467911025 Загрузка плаща вынесена в отдельный метод, реализовано отображение ссылки на плащ в запросах на текстуры 2016-09-22 19:32:00 +03:00
ErickSkrauch
a9acfb954f Биндим папку с плащами к контейнеру с приложением 2016-09-22 19:30:41 +03:00
ErickSkrauch
98b787fa99 Добавлен функционал проверки существования скина для роута /cloaks 2016-09-21 21:44:52 +03:00
ErickSkrauch
4bcd0495ed Откат изменения активного порта 2016-09-21 20:52:54 +03:00
ErickSkrauch
e03832b4e8 Добавлен роут для загрузки рендера лица скина 2016-09-21 20:52:28 +03:00
ErickSkrauch
0d6ca356d1 Merge branch 'rabbitmq_integration' into develop 2016-09-21 20:29:13 +03:00
ErickSkrauch
2477433dc9 Реализовано восстановление соединения с redis 2016-09-16 19:35:58 +03:00
ErickSkrauch
3e3ba296d5 Биндимся только к тому, что нам интересно 2016-09-15 01:29:08 +03:00
ErickSkrauch
e8bd90d8d9 Реализован функционал прослушивания RabbitMQ сообщений и соответствующие handlers для событий 2016-09-15 01:22:57 +03:00
ErickSkrauch
408d411846 Добавлен функционал сохранения id к username пользователя + метод Delete для SkinItem 2016-09-15 01:19:16 +03:00
ErickSkrauch
45007ba1c5 Merge branch 'develop' 2016-08-26 23:46:22 +03:00
ErickSkrauch
4bdab704a5 Удалено логирование ожидаемой ошибки с несуществованием скина в redis. 2016-08-26 23:45:55 +03:00
ErickSkrauch
89ea6e5ee8 Данные Redis опущены в свою папку 2016-08-26 22:05:29 +03:00
ErickSkrauch
24438fdedf Реорганизация compose файлов 2016-08-02 15:02:36 +03:00
ErickSkrauch
eeffd17ea9 Merge branch 'v3.0'
# Conflicts:
#	app.php
2016-08-02 14:20:45 +03:00
ErickSkrauch
8abb5f6bc5 Попытка вернуть пул соединений 2016-07-29 12:24:31 +03:00
ErickSkrauch
58c05533f3 Revert "Revert "Реализована страница 404 ошибки""
This reverts commit 22f80576bd.
2016-07-29 12:14:32 +03:00
ErickSkrauch
22f80576bd Revert "Реализована страница 404 ошибки"
This reverts commit c2d0cb93cb.
2016-07-29 09:56:25 +03:00
ErickSkrauch
c03021403a Исправлена ошибка, когда к скинам моянга приклеивался наш домен 2016-07-29 01:20:09 +03:00
ErickSkrauch
64bf7deb79 Корректировка под более-менее финальную версию протокола 2016-07-29 01:13:09 +03:00
ErickSkrauch
c2d0cb93cb Реализована страница 404 ошибки
Реализовано переподключение к Redis в случае, если соединение упадёт
2016-07-28 18:31:25 +03:00
ErickSkrauch
283f4e0e3f Отныне мы не используем пул соединений для редиса (Revert, прогнал тесты, убедился, что только хуже)
This reverts commit 915c465224.
2016-07-07 23:41:21 +03:00
ErickSkrauch
915c465224 Отныне мы используем пул соединений для редиса 2016-07-07 22:01:45 +03:00
ErickSkrauch
4da7a566f7 Добавлены обработчики для Legacy запросов 2016-07-07 13:10:39 +03:00
ErickSkrauch
e3f744ed10 Nginx удалён за своей ненадобностью 2016-07-07 00:46:32 +03:00
ErickSkrauch
2b8266b224 Добавлена установка значения GOMAXPROCS
Сервис роутера вынесен в глобальные
2016-07-07 00:43:42 +03:00
ErickSkrauch
3d65529d2e Добавлен обработчик NotFound
Исправлена опечатка в тесте
Разделена логика метода BuildNonElyTexturesHash
2016-07-06 14:45:14 +03:00
ErickSkrauch
c4cd95cddc Немного реструктуризации
Добавлен роут для смены скина
2016-07-06 01:25:05 +03:00
ErickSkrauch
87ca1191eb Добавлено простое Readme 2016-07-05 01:42:58 +03:00
ErickSkrauch
6a7cc9ae77 Проект разбит на более мелкие части 2016-07-05 01:28:09 +03:00
ErickSkrauch
baa1cd3010 Перенесена имплементация на Go, описано Docker compose окружение 2016-07-04 00:20:41 +03:00
ErickSkrauch
b38b78bd1e Зачистка проекта 2016-07-03 21:47:13 +03:00
81 changed files with 8838 additions and 288 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
data
vendor

13
.fossa.yml Executable file
View File

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

7
.gitignore vendored
View File

@@ -1,2 +1,5 @@
/.idea
/awstat
.idea
docker-compose.yml
docker-compose.override.yml
vendor
.cover

View File

@@ -1,5 +0,0 @@
<IfModule mod_rewrite.c>
RewriteEngine on
RewriteRule ^$ public/ [L]
RewriteRule (.*) public/$1 [L]
</IfModule>

63
.travis.yml Normal file
View File

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

134
CHANGELOG.md Normal file
View File

@@ -0,0 +1,134 @@
# 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.4.1] - 2020-04-24
### Added
- [#20](https://github.com/elyby/chrly/issues/20): Print hostname in the `version` command output.
- [#21](https://github.com/elyby/chrly/issues/21): Print Chrly's version during server startup.
### Fixed
- [#22](https://github.com/elyby/chrly/issues/22): Correct version passing during building of the Docker image.
## [4.4.0] - 2020-04-22
### Added
- Mojang textures queue now can be completely disabled via `MOJANG_TEXTURES_ENABLED` param.
- Remote mode for Mojang's textures queue with a new configuration params: `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER` and
`MOJANG_TEXTURES_UUIDS_PROVIDER_URL`.
For example, to send requests directly to [Mojang's APIs](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time),
set the next configuration:
- `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER=remote`
- `MOJANG_TEXTURES_UUIDS_PROVIDER_URL=https://api.mojang.com/users/profiles/minecraft/`
- Implemented worker mode. The app starts with the only one API endpoint: `/api/worker/mojang-uuid/{username}`,
which is compatible with [Mojang's endpoint](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time) to exchange
username to its UUID. It can be used with some load balancing software to increase throughput of Mojang's textures
proxy by splitting the load across multiple servers with its own IPs.
- Textures extra param is now can be configured via `TEXTURES_EXTRA_PARAM_NAME` and `TEXTURES_EXTRA_PARAM_VALUE`.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss`
- All incoming requests are now logging to the console in
[Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common).
- Added `/healthcheck` endpoint.
- Graceful server shutdown.
- Panics in http are now logged in Sentry.
### Fixed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and
`ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size` are now updates even if the queue is empty.
- Don't return an empty object if Mojang's textures don't contain any skin or cape.
- Provides a correct URL scheme for the cape link.
### Changed
- **BREAKING**: `QUEUE_LOOP_DELAY` param is now sets as a Go duration, not milliseconds.
For example, default value is now `2s500ms`.
- **BREAKING**: Event `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` has been renamed into
`ely.skinsystem.{hostname}.app.mojang_textures.already_scheduled`.
- Bumped Go version to 1.14.
### Removed
- **BREAKING**: `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` counter has been removed.
## [4.3.0] - 2019-11-08
### Added
- 403 Forbidden errors from the Mojang's API are now logged.
- `QUEUE_LOOP_DELAY` configuration param to adjust Mojang's textures queue performance.
### Changed
- Mojang's textures queue loop is now has an iteration delay of 2.5 seconds (was 1).
- Bumped Go version to 1.13.
## [4.2.3] - 2019-10-03
### Changed
- Mojang's textures queue batch size [reduced to 10](https://wiki.vg/index.php?title=Mojang_API&type=revision&diff=14964&oldid=14954).
- 400 BadRequest errors from the Mojang's API are now logged.
## [4.2.2] - 2019-06-19
### Fixed
- GC for in-memory textures cache has not been initialized.
## [4.2.1] - 2019-05-06
### Changed
- Improved Keep-Alive settings for HTTP client used to perform requests to Mojang's APIs.
- Mojang's textures queue now has static delay of 1 second after each iteration to prevent strange `429` errors.
- Mojang's textures queue now caches even errored responses for signed textures to avoid `429` errors.
- Mojang's textures queue now caches textures data for 70 seconds to avoid strange `429` errors.
- Mojang's textures queue now doesn't log timeout errors.
### Fixed
- Panic when Redis connection is broken.
- Duplication of Redis connections pool for Mojang's textures queue.
- Removed validation rules for `hash` field.
## [4.2.0] - 2019-05-02
### Added
- `CHANGELOG.md` file.
- [#1](https://github.com/elyby/chrly/issues/1): Restored Mojang skins proxy.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username`
- `ely.skinsystem.{hostname}.app.mojang_textures.request`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit_nil`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queued`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_miss`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.cache_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request`
- Gauges:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size`
- Timers:
- `ely.skinsystem.{hostname}.app.mojang_textures.result_time`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request_time`
### Changed
- Bumped Go version to 1.12.
- Bumped Alpine version to 3.9.3.
### Fixed
- `/textures` request no longer proxies request to Mojang in a case when there is no information about the skin,
but there is a cape.
- [#5](https://github.com/elyby/chrly/issues/5): Return Redis connection to the pool after commands are executed
### Removed
- `hash` field from `/textures` response because the game doesn't use it and calculates hash by getting the filename
from the textures link instead.
- `hash` field from `POST /api/skins` endpoint.
[Unreleased]: https://github.com/elyby/chrly/compare/4.4.1...HEAD
[4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1
[4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0
[4.3.0]: https://github.com/elyby/chrly/compare/4.2.3...4.3.0
[4.2.3]: https://github.com/elyby/chrly/compare/4.2.2...4.2.3
[4.2.2]: https://github.com/elyby/chrly/compare/4.2.1...4.2.2
[4.2.1]: https://github.com/elyby/chrly/compare/4.2.0...4.2.1
[4.2.0]: https://github.com/elyby/chrly/compare/4.1.1...4.2.0

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM alpine:3.9.3
EXPOSE 80
RUN apk add --no-cache ca-certificates
ENV STORAGE_REDIS_HOST=redis
ENV STORAGE_FILESYSTEM_HOST=/data
COPY docker-entrypoint.sh /usr/local/bin/
COPY release/chrly /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["serve"]

359
Gopkg.lock generated Normal file
View File

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

60
Gopkg.toml Normal file
View File

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

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Ely.by (http://ely.by)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

418
README.md Normal file
View File

@@ -0,0 +1,418 @@
# Chrly
[![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)
[![FOSSA Status][ico-fossa]][link-fossa]
Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's
skins system. It's packaged and distributed as a Docker image and can be downloaded from
[Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can withstand heavy loads and is
production ready.
## Installation
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` environment variable
that you must set before running `docker-compose up -d`. Other possible variables are described below.
```yml
version: '2'
services:
app:
image: elyby/chrly
hostname: chrly0
restart: always
links:
- redis
volumes:
- ./data/capes:/data/capes
ports:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
redis:
image: redis:4.0-32bit
restart: always
volumes:
- ./data/redis:/data
```
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations.
### Config
Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key
inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated.
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `stop`
and `up` it:
```sh
docker-compose stop app
docker-compose up -d app
```
**Variables to adjust:**
<table>
<thead>
<tr>
<th>ENV</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>STORAGE_REDIS_HOST</td>
<td>
By default, Chrly tries to connect to the <code>redis</code> host
(by service name in docker-compose configuration).
</td>
<td><code>localhost</code></td>
</tr>
<tr>
<td>STORAGE_REDIS_PORT</td>
<td>
Specifies the Redis connection port.
</td>
<td><code>6379</code></td>
</tr>
<tr>
<td>STORAGE_REDIS_POOL</td>
<td>By default, Chrly creates pool with 10 connection, but you may want to increase it</td>
<td><code>20</code></td>
</tr>
<tr>
<td>STATSD_ADDR</td>
<td>StatsD can be used to collect metrics</td>
<td><code>localhost:8125</code></td>
</tr>
<tr>
<td>SENTRY_DSN</td>
<td>Sentry can be used to collect app errors</td>
<td><code>https://public:private@your.sentry.io/1</code></td>
</tr>
<tr>
<td>QUEUE_LOOP_DELAY</td>
<td>
Parameter is sets the delay before each iteration of the Mojang's textures queue
(<a href="https://golang.org/pkg/time/#ParseDuration">Go's duration</a>)
</td>
<td><code>3s200ms</code></td>
</tr>
<tr>
<td>QUEUE_BATCH_SIZE</td>
<td>
Sets the count of usernames, which will be sent to the
<a href="https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs">Mojang's API to exchange them to their UUIDs</a>.
The current limit is <code>10</code>, but it may change in the future, so you may want to adjust it.
</td>
<td><code>10</code></td>
</tr>
<tr>
<td>MOJANG_TEXTURES_ENABLED</td>
<td>
Allows to completely disable Mojang textures provider for unknown usernames. Enabled by default.
</td>
<td><code>true</code></td>
</tr>
<tr>
<td id="remote-mojang-uuids-provider">MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER</td>
<td>
Specifies the preferred provider of the Mojang's UUIDs. Takes <code>remote</code> value.
In any other case, the local queue will be used.
</td>
<td><code>remote</code></td>
</tr>
<tr>
<td>MOJANG_TEXTURES_UUIDS_PROVIDER_URL</td>
<td>
When the UUIDs driver set to <code>remote</code>, sets the remote URL.
The trailing slash won't cause any problems.
</td>
<td><code>http://remote-provider.com/api/worker/mojang-uuid</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_NAME</td>
<td>
Sets the name of the extra property in the
<a href="#get-texturessignedusername">signed textures</a> response.
</td>
<td><code>your-name</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_VALUE</td>
<td>
Sets the value of the extra property in the
<a href="#get-texturessignedusername">signed textures</a> response.
</td>
<td><code>your awesome joke!</code></td>
</tr>
</tbody>
</table>
If something goes wrong, you can always access logs by executing `docker-compose logs -f app`.
## Endpoints
Each endpoint that accepts `username` as a part of an url takes it case insensitive. `.png` part can be omitted too.
#### `GET /skins/{username}.png`
This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll
respond with the `301` redirect to that url. If the skin entry isn't found, it'll request textures information from
Mojang's API and if it has a skin, than it'll return a `301` redirect to it.
#### `GET /cloaks/{username}.png`
It responds to requested `username` with a cape texture. If the cape entry isn't found, it'll request textures
information from Mojang's API and if it has a cape, than it'll return a `301` redirect to it.
#### `GET /textures/{username}`
This endpoint forms response payloads as if it was the `textures`' property, but without base64 encoding. For example:
```json
{
"SKIN": {
"url": "http://example.com/skin.png",
"metadata": {
"model": "slim"
}
},
"CAPE": {
"url": "http://example.com/cape.png"
}
}
```
If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has
a textures property, than it'll return decoded contents.
That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
to the Chrly server and put the result in your hasJoined response.
#### `GET /textures/signed/{username}`
Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if
you have your own source of Mojang's signatures, then you can pass it with textures and it'll be displayed in response
of this endpoint. Received response should be directly sent to the client without any modification via game server API.
Response example:
```json
{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "username",
"properties": [
{
"name": "textures",
"signature": "signature value",
"value": "base64 encoded value"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}
```
If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent.
You can adjust URL to `/textures/signed/{username}?proxy=true` to obtain textures information for provided username
from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in
the example above.
#### `GET /skins?name={username}`
Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username
placeholder wasn't used.
#### `GET /cloaks?name={username}`
Equivalent of the `GET /cloaks/{username}.png`, but constructed especially for old Minecraft versions, where username
placeholder wasn't used.
### Records manipulating API
Each request to the internal API should be performed with the Bearer authorization header. Example curl request:
```sh
curl -X POST -i http://chrly.domain.com/api/skins \
-H "Authorization: Bearer Ym9zY236Ym9zY28="
```
You can obtain token by executing `docker-compose run --rm app token`.
#### `POST /api/skins`
> **Warning**: skin uploading via `skin` field is not implemented for now.
Endpoint allows you to create or update skin record for a username. To upload skin, you have to send multipart
form data. `form-urlencoded` also supported, but, as you may know, it doesn't support files uploading.
**Request params:**
| Field | Type | Description |
|-----------------|--------|--------------------------------------------------------------------------------|
| identityId | int | Unique record identifier. |
| username | string | Username. Case insensitive. |
| uuid | uuid | UUID of the user. |
| skinId | int | Skin identifier. |
| is1_8 | bool | Does the skin have the new format (64x64). |
| isSlim | bool | Does skin have slim arms (Alex model). |
| mojangTextures | string | Mojang textures field. It must be a base64 encoded json string. Not required. |
| mojangSignature | string | Signature for Mojang textures, which is required when `mojangTextures` passed. |
| url | string | Actual url of the skin. You have to pass this parameter or `skin`. |
| skin | file | Skin file. You have to pass this parameter or `url`. |
If successful you'll receive `201` status code. In the case of failure there will be `400` status code and errors list
as json:
```json
{
"errors": {
"identityId": [
"The identityId field must be numeric"
]
}
}
```
#### `DELETE /api/skins/id:{identityId}`
Performs record removal by identity id. Request body is not required. On success you will receive `204` status code.
On failure it'll be `404` with the json body:
```json
{
"error": "Cannot find record for requested user id"
}
```
#### `DELETE /api/skins/{username}`
Same endpoint as above but it removes record by identity's username. Have the same behavior, but in case of failure
response will be:
```json
{
"error": "Cannot find record for requested username"
}
```
### Worker mode
The worker mode can be used in cooperation with the [remote server mode](#remote-mojang-uuids-provider)
to exchange Mojang usernames to UUIDs. This mode by itself doesn't solve the problem of
[extremely strict limits](https://github.com/elyby/chrly/issues/10) on the number of requests to the Mojang's API.
But with a proxying load balancer (e.g. HAProxy, Nginx, etc.) it's easy to build a cluster of workers,
which will multiply the bandwidth of the exchanging usernames to its UUIDs.
The instructions for setting up a proxy load balancer are outside the context of this documentation,
but you get the idea ;)
#### `GET /api/worker/mojang-uuid/{username}`
Performs [batch usernames exchange to UUIDs](https://github.com/elyby/chrly/issues/1) and returns the result in the
[same format as it returns from the Mojang's API](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time):
```json
{
"id": "3e3ee6c35afa48abb61e8cd8c42fc0d9",
"name": "ErickSkrauch"
}
```
> **Note**: the results aren't cached.
### Health check
#### `GET /healthcheck`
This endpoint can be used to programmatically check the status of the server.
If all internal checks are successful, the server will return `200` status code with the following body:
```json
{
"status": "OK"
}
```
If any of the checks fails, the server will return `503` status code with the following body:
```json
{
"status": "Service Unavailable",
"errors": {
"mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded"
}
}
```
## Development
First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH`
environment variable.
This project uses [`dep`](https://github.com/golang/dep) for dependencies management, so it
[should be installed](https://github.com/golang/dep#installation) too.
Then you must fork this repository. Now follow these steps:
```sh
# Get the source code
go get github.com/elyby/chrly
# Switch to the project folder
cd $GOPATH/src/github.com/elyby/chrly
# Install dependencies (it can take a while)
dep ensure
# Add your fork link as a remote
git remote add fork git@github.com:your-username/chrly.git
# Create a new branch for your task
git checkout -b iss-123
```
You only need to execute `go run main.go` to run the project, but without Redis database and a secret key it won't work
for very long. You have to export `CHRLY_SECRET` environment variable globally or pass it via `env`:
```sh
env CHRLY_SECRET=some_local_secret go run main.go serve
```
Redis can be installed manually, but if you have [Docker installed](https://docs.docker.com/install/), you can run
predefined docker-compose service. Simply execute the next commands:
```sh
cp docker-compose.dev.yml docker-compose.yml
docker-compose up -d
```
If your Redis instance isn't located at the `localhost`, you can change host by editing environment variable
`STORAGE_REDIS_HOST`.
After all of that `go run main.go serve` should successfully start the application.
To run tests execute `go test ./...`.
## License
[![FOSSA Status][ico-fossa-big]][link-fossa]
[ico-lang]: https://img.shields.io/badge/lang-go%201.14-blue.svg?style=flat-square
[ico-build]: https://img.shields.io/travis/elyby/chrly.svg?style=flat-square
[ico-coverage]: https://img.shields.io/codecov/c/github/elyby/chrly.svg?style=flat-square
[ico-changelog]: https://img.shields.io/badge/keep%20a-changelog-orange.svg?style=flat-square
[ico-license]: https://img.shields.io/github/license/elyby/chrly.svg?style=flat-square
[ico-fossa]: https://app.fossa.io/api/projects/git%2Bgithub.com%2Felyby%2Fchrly.svg?type=shield
[ico-fossa-big]: https://app.fossa.io/api/projects/git%2Bgithub.com%2Felyby%2Fchrly.svg?type=large
[link-go]: https://golang.org
[link-build]: https://travis-ci.org/elyby/chrly
[link-coverage]: https://codecov.io/gh/elyby/chrly
[link-fossa]: https://app.fossa.io/projects/git%2Bgithub.com%2Felyby%2Fchrly

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

@@ -0,0 +1,208 @@
package mojang
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"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) {
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := "https://sessionserver.mojang.com/session/minecraft/profile/" + normalizedUuid
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 == 403:
return &ForbiddenError{}
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 "200: 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 fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
}
func (*BadRequestError) IsMojangError() bool {
return true
}
// When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization)
type ForbiddenError struct {
ResponseError
}
func (*ForbiddenError) Error() string {
return "403: Forbidden"
}
// When you exceed the set limit of requests, this error will be returned
type TooManyRequestsError struct {
ResponseError
}
func (*TooManyRequestsError) Error() string {
return "429: 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 fmt.Sprintf("%d: %s", e.Status, "Server error")
}
func (*ServerError) IsMojangError() bool {
return true
}

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

@@ -0,0 +1,310 @@
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, "400 IllegalArgumentException: profileName can not be null or empty.")
assert.Implements((*ResponseError)(nil), err)
})
t.Run("handle forbidden response", func(t *testing.T) {
assert := testify.New(t)
defer gock.Off()
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(403).
BodyString("just because")
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"})
assert.Nil(result)
assert.IsType(&ForbiddenError{}, err)
assert.EqualError(err, "403: Forbidden")
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, "429: 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, "500: 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 with dashed uuid", 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("4566e69f-c907-48ee-8d71-d7ba5aa00d20", 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, "200: 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, "429: 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, "500: Server error")
assert.Equal(500, err.(*ServerError).Status)
assert.Implements((*ResponseError)(nil), err)
})
}

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)
})
}
}

98
app.php
View File

@@ -1,98 +0,0 @@
<?php
define('ENCODING', 'UTF-8');
$app->get('/skins/{nickname}', function ($nickname) use ($app) {
// $systemVersion = $app->request->get('version', 'int');
// $minecraftVersion = $app->request->get('minecraft_version', 'string');
// На всякий случай проверка на наличие .png для файла
if (strrpos($nickname, '.png') != -1) {
$nickname = explode('.', $nickname)[0];
}
// TODO: восстановить функцию деградации скинов
$skin = Skins::findByNickname($nickname);
if (!$skin || $skin->skinId == 0) {
return $app->response->redirect('http://skins.minecraft.net/MinecraftSkins/' . $nickname . '.png', true);
}
return $app->response->redirect($skin->url);
})->setName('skinSystem');
$app->get('/cloaks/{nickname}', function ($nickname) use ($app) {
// На всякий случай проверка на наличие .png для файла
if (strrpos($nickname, '.png') != -1) {
$nickname = explode('.', $nickname)[0];
}
return $app->response->redirect('http://skins.minecraft.net/MinecraftCloaks/'.$nickname.'.png');
});
$app->get('/textures/{nickname}', function($nickname) use ($app) {
$skin = Skins::findByNickname($nickname);
if ($skin && $skin->skinId != 0) {
$url = $skin->url;
$hash = $skin->hash;
} else {
$url = 'http://skins.minecraft.net/MinecraftSkins/'.$nickname.'.png';
$hash = md5('non-ely-' . mktime(date('H'), 0, 0) . '-' . $nickname);
}
// TODO: в authserver.ely.by есть готовый класс для работы с форматом текстур. Так что если мы его вынесем в
// common library, то нужно будет заменить его здесь
$textures = [
'SKIN' => [
'url' => $url,
'hash' => $hash,
],
];
$capePath = __DIR__ . '/cloaks/' . $nickname . '.png';
if (file_exists($capePath)) {
$textures['CAPE'] = [
'url' => '/cloaks/' . mb_convert_case($nickname, MB_CASE_LOWER) . '.png',
'hash' => md5_file($capePath),
];
}
if ($skin && $skin->isSlim) {
$textures['SKIN']['metadata']['model'] = 'slim';
}
return $app->response->setContentType('application/json')->setJsonContent($textures);
});
$app->post('/system/setSkin', function() use ($app) {
$headers = getallheaders();
if (!array_key_exists('X-Ely-key', $headers) || $headers['X-Ely-key'] != '43fd2ce61b3f5704dfd729c1f2d6ffdb') {
return $app->response->setStatusCode(403, 'Forbidden')->setContent('Хорошая попытка, мерзкий хакер.');
}
$request = $app->request;
$nickname = mb_convert_case($request->getPost('nickname', 'string'), MB_CASE_LOWER, ENCODING);
$skin = Skins::findByNickname($nickname);
if (!$skin) {
$skin = new Skins();
$skin->nickname = $nickname;
}
$skin->userId = (int) $request->getPost('userId', 'int');
$skin->skinId = (int) $request->getPost('skinId', 'int');
$skin->hash = $request->getPost('hash', 'string');
$skin->is1_8 = (bool) $request->getPost('is1_8', 'int');
$skin->isSlim = (bool) $request->getPost('isSlim', 'int');
$skin->url = $request->getPost('url', 'string');
return $app->response->setContent($skin->save() ? 'OK' : 'ERROR');
});
$app->notFound(function () use ($app) {
$app->response
->setStatusCode(404, 'Not Found')
->setContent('Not Found<br /> <a href="http://ely.by">Система скинов Ely.by</a>.')
->send();
});

67
cmd/root.go Normal file
View File

@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"log"
"os"
"strings"
. "github.com/goava/di"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/di"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/version"
)
var RootCmd = &cobra.Command{
Use: "chrly",
Short: "Implementation of Minecraft skins system server",
Version: version.Version(),
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := RootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func shouldGetContainer() *Container {
container, err := di.New()
if err != nil {
panic(err)
}
return container
}
func startServer(modules []string) {
container := shouldGetContainer()
var config *viper.Viper
err := container.Resolve(&config)
if err != nil {
log.Fatal(err)
}
config.Set("modules", modules)
err = container.Invoke(http.StartServer)
if err != nil {
log.Fatal(err)
}
}
func init() {
cobra.OnInitialize(initConfig)
}
func initConfig() {
viper.AutomaticEnv()
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
}

17
cmd/serve.go Normal file
View File

@@ -0,0 +1,17 @@
package cmd
import (
"github.com/spf13/cobra"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Starts HTTP handler for the skins system",
Run: func(cmd *cobra.Command, args []string) {
startServer([]string{"skinsystem", "api"})
},
}
func init() {
RootCmd.AddCommand(serveCmd)
}

34
cmd/token.go Normal file
View File

@@ -0,0 +1,34 @@
package cmd
import (
"fmt"
"log"
"github.com/elyby/chrly/http"
"github.com/spf13/cobra"
)
var tokenCmd = &cobra.Command{
Use: "token",
Short: "Creates a new token, which allows to interact with Chrly API",
Run: func(cmd *cobra.Command, args []string) {
container := shouldGetContainer()
var auth *http.JwtAuth
err := container.Resolve(&auth)
if err != nil {
log.Fatal(err)
}
token, err := auth.NewToken(http.SkinScope)
if err != nil {
log.Fatalf("Unable to create new token. The error is %v\n", err)
}
fmt.Printf("%s\n", token)
},
}
func init() {
RootCmd.AddCommand(tokenCmd)
}

32
cmd/version.go Normal file
View File

@@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
"github.com/elyby/chrly/version"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show the Chrly version information",
Run: func(cmd *cobra.Command, args []string) {
hostname, err := os.Hostname()
if err != nil {
hostname = "<unknown>"
}
fmt.Printf("Version: %s\n", version.Version())
fmt.Printf("Commit: %s\n", version.Commit())
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Printf("Hostname: %s\n", hostname)
},
}
func init() {
RootCmd.AddCommand(versionCmd)
}

17
cmd/worker.go Normal file
View File

@@ -0,0 +1,17 @@
package cmd
import (
"github.com/spf13/cobra"
)
var workerCmd = &cobra.Command{
Use: "worker",
Short: "Starts HTTP handler for the Mojang usernames to UUIDs worker",
Run: func(cmd *cobra.Command, args []string) {
startServer([]string{"worker"})
},
}
func init() {
RootCmd.AddCommand(workerCmd)
}

View File

@@ -1,15 +0,0 @@
<?php
return new \Phalcon\Config([
'mongo' => [
'host' => 'localhost',
'port' => 27017,
'username' => '',
'password' => '',
'dbname' => 'ely_skins',
],
'application' => [
'modelsDir' => __DIR__ . '/../models/',
'baseUri' => '/',
]
]);

View File

@@ -1,12 +0,0 @@
<?php
/**
* @var \Phalcon\Config $config
*/
$loader = new \Phalcon\Loader();
$loader->registerDirs(array(
$config->application->modelsDir
));
$loader->register();

View File

@@ -1,46 +0,0 @@
<?php
/**
* @var \Phalcon\Config $config
*/
use Phalcon\Mvc\Collection\Manager;
use Phalcon\Mvc\View;
use Phalcon\Mvc\Url as UrlResolver;
use Phalcon\DI\FactoryDefault;
$di = new FactoryDefault();
$di->set('view', function () {
$view = new View();
$view->disable();
return $view;
});
/**
* The URL component is used to generate all kind of urls in the application
*/
$di->set('url', function () use ($config) {
$url = new UrlResolver();
$url->setBaseUri($config->application->baseUri);
return $url;
});
$di->set('mongo', function() use ($config) {
/** @var StdClass $mongoConfig */
$mongoConfig = $config->mongo;
$connectionString = 'mongodb://';
if ($mongoConfig->username && $mongoConfig->password) {
$connectionString .= "{$mongoConfig->username}:{$mongoConfig->password}@";
}
$connectionString .= $mongoConfig->host . ':' . $mongoConfig->port;
$mongo = new MongoClient($connectionString);
return $mongo->selectDb($mongoConfig->dbname);
});
$di->setShared('collectionManager', function() {
return new Manager();
});

2
data/redis/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

33
db/fs/fs.go Normal file
View File

@@ -0,0 +1,33 @@
package fs
import (
"os"
"path"
"strings"
"github.com/elyby/chrly/model"
)
func New(basePath string) (*Filesystem, error) {
return &Filesystem{path: basePath}, nil
}
type Filesystem struct {
path string
}
func (f *Filesystem) FindCapeByUsername(username string) (*model.Cape, error) {
capePath := path.Join(f.path, strings.ToLower(username)+".png")
file, err := os.Open(capePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &model.Cape{
File: file,
}, nil
}

View File

@@ -0,0 +1,56 @@
package fs
import (
"fmt"
"io/ioutil"
"os"
"path"
"testing"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
fs, err := New("base/path")
require.Nil(t, err)
require.Equal(t, "base/path", fs.path)
}
func TestFilesystem(t *testing.T) {
t.Run("FindCapeByUsername", func(t *testing.T) {
dir, err := ioutil.TempDir("", "capes")
if err != nil {
panic(fmt.Errorf("cannot crete temp directory for tests: %w", err))
}
defer os.RemoveAll(dir)
t.Run("exists cape", func(t *testing.T) {
file, err := os.Create(path.Join(dir, "username.png"))
if err != nil {
panic(fmt.Errorf("cannot create temp skin for tests: %w", err))
}
defer os.Remove(file.Name())
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("username")
require.Nil(t, err)
require.NotNil(t, cape)
capeFile, _ := cape.File.(*os.File)
require.Equal(t, file.Name(), capeFile.Name())
})
t.Run("not exists cape", func(t *testing.T) {
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("username")
require.Nil(t, err)
require.Nil(t, cape)
})
t.Run("empty username", func(t *testing.T) {
fs, _ := New(dir)
cape, err := fs.FindCapeByUsername("")
require.Nil(t, err)
require.Nil(t, cape)
})
})
}

270
db/redis/redis.go Normal file
View File

@@ -0,0 +1,270 @@
package redis
import (
"bytes"
"compress/zlib"
"encoding/json"
"io"
"strconv"
"strings"
"time"
"github.com/mediocregopher/radix.v2/pool"
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix.v2/util"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
var now = time.Now
func New(addr string, poolSize int) (*Redis, error) {
conn, err := pool.New("tcp", addr, poolSize)
if err != nil {
return nil, err
}
return &Redis{
pool: conn,
}, nil
}
const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username"
const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid"
type Redis struct {
pool *pool.Pool
}
func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) {
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
return findByUsername(username, conn)
}
func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
redisKey := buildUsernameKey(username)
response := conn.Cmd("GET", redisKey)
if response.IsType(redis.Nil) {
return nil, nil
}
encodedResult, _ := response.Bytes()
result, err := zlibDecode(encodedResult)
if err != nil {
return nil, err
}
var skin *model.Skin
err = json.Unmarshal(result, &skin)
if err != nil {
return nil, err
}
skin.OldUsername = skin.Username
return skin, nil
}
func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) {
conn, err := db.pool.Get()
if err != nil {
return nil, err
}
defer db.pool.Put(conn)
return findByUserId(id, conn)
}
func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
response := conn.Cmd("HGET", accountIdToUsernameKey, id)
if response.IsType(redis.Nil) {
return nil, nil
}
username, err := response.Str()
if err != nil {
return nil, err
}
return findByUsername(username, conn)
}
func (db *Redis) SaveSkin(skin *model.Skin) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return save(skin, conn)
}
func save(skin *model.Skin, conn util.Cmder) error {
conn.Cmd("MULTI")
// If user has changed username, then we must delete his old username record
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
conn.Cmd("DEL", buildUsernameKey(skin.OldUsername))
}
// If this is a new record or if the user has changed username, we set the value in the hash table
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username)
}
str, _ := json.Marshal(skin)
conn.Cmd("SET", buildUsernameKey(skin.Username), zlibEncode(str))
conn.Cmd("EXEC")
skin.OldUsername = skin.Username
return nil
}
func (db *Redis) RemoveSkinByUserId(id int) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUserId(id, conn)
}
func removeByUserId(id int, conn util.Cmder) error {
record, err := findByUserId(id, conn)
if err != nil {
return err
}
conn.Cmd("MULTI")
conn.Cmd("HDEL", accountIdToUsernameKey, id)
if record != nil {
conn.Cmd("DEL", buildUsernameKey(record.Username))
}
conn.Cmd("EXEC")
return nil
}
func (db *Redis) RemoveSkinByUsername(username string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return removeByUsername(username, conn)
}
func removeByUsername(username string, conn util.Cmder) error {
record, err := findByUsername(username, conn)
if err != nil {
return err
}
if record == nil {
return nil
}
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
conn.Cmd("EXEC")
return nil
}
func (db *Redis) GetUuid(username string) (string, error) {
conn, err := db.pool.Get()
if err != nil {
return "", err
}
defer db.pool.Put(conn)
return findMojangUuidByUsername(username, conn)
}
func findMojangUuidByUsername(username string, conn util.Cmder) (string, error) {
response := conn.Cmd("HGET", mojangUsernameToUuidKey, strings.ToLower(username))
if response.IsType(redis.Nil) {
return "", &mojangtextures.ValueNotFound{}
}
data, _ := response.Str()
parts := strings.Split(data, ":")
timestamp, _ := strconv.ParseInt(parts[1], 10, 64)
storedAt := time.Unix(timestamp, 0)
if storedAt.Add(time.Hour * 24 * 30).Before(now()) {
return "", &mojangtextures.ValueNotFound{}
}
return parts[0], nil
}
func (db *Redis) StoreUuid(username string, uuid string) error {
conn, err := db.pool.Get()
if err != nil {
return err
}
defer db.pool.Put(conn)
return storeMojangUuid(username, uuid, conn)
}
func storeMojangUuid(username string, uuid string, conn util.Cmder) error {
value := uuid + ":" + strconv.FormatInt(now().Unix(), 10)
res := conn.Cmd("HSET", mojangUsernameToUuidKey, strings.ToLower(username), value)
if res.IsType(redis.Err) {
return res.Err
}
return nil
}
func (db *Redis) Ping() error {
r := db.pool.Cmd("PING")
if r.Err != nil {
return r.Err
}
return nil
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
_, _ = writer.Write(str)
_ = writer.Close()
return buff.Bytes()
}
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, err := zlib.NewReader(buff)
if err != nil {
return nil, err
}
resultBuffer := new(bytes.Buffer)
_, _ = io.Copy(resultBuffer, reader)
_ = reader.Close()
return resultBuffer.Bytes(), nil
}

View File

@@ -0,0 +1,361 @@
// +build redis
package redis
import (
"fmt"
"reflect"
"testing"
"time"
"github.com/mediocregopher/radix.v2/redis"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
"github.com/elyby/chrly/mojangtextures"
)
const redisAddr = "localhost:6379"
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
internalPool := reflect.ValueOf(conn.pool).Elem().FieldByName("pool")
assert.Equal(t, 12, internalPool.Cap())
})
t.Run("should return error", func(t *testing.T) {
conn, err := New("localhost:12345", 12) // Use localhost to avoid DNS resolution
assert.Error(t, err)
assert.Nil(t, conn)
})
}
type redisTestSuite struct {
suite.Suite
Redis *Redis
cmd func(cmd string, args ...interface{}) *redis.Resp
}
func (suite *redisTestSuite) SetupSuite() {
conn, err := New(redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
suite.Redis = conn
suite.cmd = conn.pool.Cmd
}
func (suite *redisTestSuite) SetupTest() {
// Cleanup database before the each test
suite.cmd("FLUSHALL")
}
func (suite *redisTestSuite) TearDownTest() {
// Restore time.Now func
now = time.Now
}
func (suite *redisTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
}
func TestRedis(t *testing.T) {
suite.Run(t, new(redisTestSuite))
}
/**
* JSON with zlib encoding
* {
* userId: 1,
* uuid: "fd5da1e4d66d4d17aadee2446093896d",
* username: "Mock",
* skinId: 1,
* url: "http://localhost/skin.png",
* is1_8: true,
* isSlim: false,
* mojangTextures: "mock-mojang-textures",
* mojangSignature: "mock-mojang-signature"
* }
*/
var skinRecord = []byte{
0x78, 0x9c, 0x5c, 0xce, 0x4b, 0x4a, 0x4, 0x41, 0xc, 0xc6, 0xf1, 0xbb, 0x7c, 0xeb, 0x1a, 0xdb, 0xd6, 0xb2,
0x9c, 0xc9, 0xd, 0x5c, 0x88, 0x8b, 0xd1, 0xb5, 0x84, 0x4e, 0xa6, 0xa7, 0xec, 0x7a, 0xc, 0xf5, 0x0, 0x41,
0xbc, 0xbb, 0xb4, 0xd2, 0xa, 0x2e, 0xf3, 0xe3, 0x9f, 0x90, 0xf, 0xf4, 0xaa, 0xe5, 0x41, 0x40, 0xa3, 0x41,
0xef, 0x5e, 0x40, 0x38, 0xc9, 0x9d, 0xf0, 0xa8, 0x56, 0x9c, 0x13, 0x2b, 0xe3, 0x3d, 0xb3, 0xa8, 0xde, 0x58,
0xeb, 0xae, 0xf, 0xb7, 0xfb, 0x83, 0x13, 0x98, 0xef, 0xa5, 0xc4, 0x51, 0x41, 0x78, 0xcc, 0xd3, 0x2, 0x83,
0xba, 0xf8, 0xb4, 0x9d, 0x29, 0x1, 0x84, 0x73, 0x6b, 0x17, 0x1a, 0x86, 0x90, 0x27, 0xe, 0xe7, 0x5c, 0xdb,
0xb0, 0x16, 0x57, 0x97, 0x34, 0xc3, 0xc0, 0xd7, 0xf1, 0x75, 0xf, 0x6a, 0xa5, 0xeb, 0x3a, 0x1c, 0x83, 0x8f,
0xa0, 0x13, 0x87, 0xaa, 0x6, 0x31, 0xbf, 0x71, 0x9a, 0x9f, 0xf5, 0xbd, 0xf5, 0xa2, 0x15, 0x84, 0x98, 0xa7,
0x65, 0xf7, 0xa3, 0xbb, 0xb6, 0xf1, 0xd6, 0x1d, 0xfd, 0x9c, 0x78, 0xa5, 0x7f, 0x61, 0xfd, 0x75, 0x83, 0xa7,
0x20, 0x2f, 0x7f, 0xff, 0xe2, 0xf3, 0x2b, 0x0, 0x0, 0xff, 0xff, 0x6f, 0xdd, 0x51, 0x71,
}
func (suite *redisTestSuite) TestFindSkinByUsername() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(err)
suite.Require().NotNil(skin)
suite.Require().Equal(1, skin.UserId)
suite.Require().Equal("fd5da1e4d66d4d17aadee2446093896d", skin.Uuid)
suite.Require().Equal("Mock", skin.Username)
suite.Require().Equal(1, skin.SkinId)
suite.Require().Equal("http://localhost/skin.png", skin.Url)
suite.Require().True(skin.Is1_8)
suite.Require().False(skin.IsSlim)
suite.Require().Equal("mock-mojang-textures", skin.MojangTextures)
suite.Require().Equal("mock-mojang-signature", skin.MojangSignature)
suite.Require().Equal(skin.Username, skin.OldUsername)
})
suite.RunSubTest("not exists record", func() {
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
suite.RunSubTest("invalid zlib encoding", func() {
suite.cmd("SET", "username:mock", "this is really not zlib")
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(skin)
suite.Require().EqualError(err, "zlib: invalid header")
})
suite.RunSubTest("invalid json encoding", func() {
suite.cmd("SET", "username:mock", []byte{
0x78, 0x9c, 0xca, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x28, 0xcf, 0x2f, 0xca, 0x49, 0x1, 0x4, 0x0, 0x0, 0xff,
0xff, 0x1a, 0xb, 0x4, 0x5d,
})
skin, err := suite.Redis.FindSkinByUsername("Mock")
suite.Require().Nil(skin)
suite.Require().EqualError(err, "invalid character 'h' looking for beginning of value")
})
}
func (suite *redisTestSuite) TestFindSkinByUserId() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().NotNil(skin)
suite.Require().Equal(1, skin.UserId)
})
suite.RunSubTest("not exists record", func() {
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
suite.RunSubTest("exists hash record, but no skin record", func() {
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
skin, err := suite.Redis.FindSkinByUserId(1)
suite.Require().Nil(err)
suite.Require().Nil(skin)
})
}
func (suite *redisTestSuite) TestSaveSkin() {
suite.RunSubTest("save new entity", func() {
err := suite.Redis.SaveSkin(&model.Skin{
UserId: 1,
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
Username: "Mock",
SkinId: 1,
Url: "http://localhost/skin.png",
Is1_8: true,
IsSlim: false,
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
})
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().False(usernameResp.IsType(redis.Nil))
bytes, _ := usernameResp.Bytes()
suite.Require().Equal(skinRecord, bytes)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().False(usernameResp.IsType(redis.Nil))
str, _ := idResp.Str()
suite.Require().Equal("Mock", str)
})
suite.RunSubTest("save exists record with changed username", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.SaveSkin(&model.Skin{
UserId: 1,
Uuid: "fd5da1e4d66d4d17aadee2446093896d",
Username: "NewMock",
SkinId: 1,
Url: "http://localhost/skin.png",
Is1_8: true,
IsSlim: false,
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
OldUsername: "Mock",
})
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:newmock")
suite.Require().False(usernameResp.IsType(redis.Nil))
bytes, _ := usernameResp.Bytes()
suite.Require().Equal([]byte{
0x78, 0x9c, 0x5c, 0x8e, 0xcb, 0x4e, 0xc3, 0x40, 0xc, 0x45, 0xff, 0xe5, 0xae, 0xa7, 0x84, 0x40, 0x18, 0x5a,
0xff, 0x1, 0xb, 0x60, 0x51, 0x58, 0x23, 0x2b, 0x76, 0xd3, 0x21, 0xf3, 0xa8, 0xe6, 0x21, 0x90, 0x10, 0xff,
0x8e, 0x52, 0x14, 0x90, 0xba, 0xf4, 0xd1, 0xf1, 0xd5, 0xf9, 0x42, 0x2b, 0x9a, 0x1f, 0x4, 0xd4, 0x1b, 0xb4,
0xe6, 0x4, 0x84, 0x83, 0xdc, 0x9, 0xf7, 0x3a, 0x88, 0xb5, 0x32, 0x48, 0x7f, 0xcf, 0x2c, 0xaa, 0x37, 0xc3,
0x60, 0xaf, 0x77, 0xb7, 0xdb, 0x9d, 0x15, 0x98, 0xf3, 0x53, 0xe4, 0xa0, 0x20, 0x3c, 0xe9, 0xc7, 0x63, 0x1a,
0x67, 0x18, 0x94, 0xd9, 0xc5, 0x75, 0x29, 0x7b, 0x10, 0x8e, 0xb5, 0x9e, 0xa8, 0xeb, 0x7c, 0x1a, 0xd9, 0x1f,
0x53, 0xa9, 0xdd, 0x62, 0x5c, 0x9d, 0xe2, 0x4, 0x3, 0x57, 0xfa, 0xb7, 0x2d, 0xa8, 0xe6, 0xa6, 0xcb, 0xb1,
0xf7, 0x2e, 0x80, 0xe, 0xec, 0x8b, 0x1a, 0x84, 0xf4, 0xce, 0x71, 0x7a, 0xd1, 0xcf, 0xda, 0xb2, 0x16, 0x10,
0x42, 0x1a, 0xe7, 0xcd, 0x2f, 0xdd, 0xd4, 0x15, 0xaf, 0xde, 0xde, 0x4d, 0x91, 0x17, 0x74, 0x21, 0x96, 0x3f,
0x6e, 0xf0, 0xec, 0xe5, 0xf5, 0x3f, 0xf9, 0xdc, 0xfb, 0xfd, 0x13, 0x0, 0x0, 0xff, 0xff, 0xca, 0xc3, 0x54,
0x25,
}, bytes)
oldUsernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(oldUsernameResp.IsType(redis.Nil))
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().False(usernameResp.IsType(redis.Nil))
str, _ := idResp.Str()
suite.Require().Equal("NewMock", str)
})
}
func (suite *redisTestSuite) TestRemoveSkinByUserId() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
})
suite.RunSubTest("exists only id", func() {
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().Nil(err)
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
})
suite.RunSubTest("error when querying skin record", func() {
suite.cmd("SET", "username:mock", "invalid zlib")
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUserId(1)
suite.Require().EqualError(err, "zlib: invalid header")
})
}
func (suite *redisTestSuite) TestRemoveSkinByUsername() {
suite.RunSubTest("exists record", func() {
suite.cmd("SET", "username:mock", skinRecord)
suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
idResp := suite.cmd("HGET", "hash:username-to-account-id", 1)
suite.Require().True(idResp.IsType(redis.Nil))
})
suite.RunSubTest("exists only username", func() {
suite.cmd("SET", "username:mock", skinRecord)
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
usernameResp := suite.cmd("GET", "username:mock")
suite.Require().True(usernameResp.IsType(redis.Nil))
})
suite.RunSubTest("no records", func() {
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().Nil(err)
})
suite.RunSubTest("error when querying skin record", func() {
suite.cmd("SET", "username:mock", "invalid zlib")
err := suite.Redis.RemoveSkinByUsername("Mock")
suite.Require().EqualError(err, "zlib: invalid header")
})
}
func (suite *redisTestSuite) TestGetUuid() {
suite.RunSubTest("exists record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
suite.Require().Nil(err)
suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
suite.RunSubTest("not exists record", func() {
uuid, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
})
suite.RunSubTest("exists, but expired record", func() {
suite.cmd("HSET",
"hash:mojang-username-to-uuid",
"mock",
fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()),
)
uuid, err := suite.Redis.GetUuid("Mock")
suite.Require().Empty(uuid)
suite.Require().IsType(new(mojangtextures.ValueNotFound), err)
})
}
func (suite *redisTestSuite) TestStoreUuid() {
now = func() time.Time {
return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC)
}
err := suite.Redis.StoreUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd")
suite.Require().Nil(err)
resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock")
suite.Require().False(resp.IsType(redis.Nil))
str, _ := resp.Str()
suite.Require().Equal(str, "d3ca513eb3e14946b58047f2bd3530fd:1587435016")
}
func (suite *redisTestSuite) TestPing() {
err := suite.Redis.Ping()
suite.Require().Nil(err)
}

14
di/config.go Normal file
View File

@@ -0,0 +1,14 @@
package di
import (
"github.com/goava/di"
"github.com/spf13/viper"
)
var config = di.Options(
di.Provide(newConfig),
)
func newConfig() *viper.Viper {
return viper.GetViper()
}

73
di/db.go Normal file
View File

@@ -0,0 +1,73 @@
package di
import (
"fmt"
"path"
"github.com/goava/di"
"github.com/spf13/viper"
"github.com/elyby/chrly/db/fs"
"github.com/elyby/chrly/db/redis"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
// v4 had the idea that it would be possible to separate backends for storing skins and capes.
// But in v5 the storage will be unified, so this is just temporary constructors before large reworking.
//
// Since there are no options for selecting target backends,
// all constants in this case point to static specific implementations.
var db = di.Options(
di.Provide(newRedis,
di.As(new(http.SkinsRepository)),
di.As(new(mojangtextures.UuidsStorage)),
),
di.Provide(newFSFactory,
di.As(new(http.CapesRepository)),
),
di.Provide(newMojangSignedTexturesStorage),
)
func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) {
config.SetDefault("storage.redis.host", "localhost")
config.SetDefault("storage.redis.port", 6379)
config.SetDefault("storage.redis.poll", 10)
conn, err := redis.New(
fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")),
config.GetInt("storage.redis.poolSize"),
)
if err != nil {
return nil, err
}
if err := container.Provide(func() *namedHealthChecker {
return &namedHealthChecker{
Name: "redis",
Checker: es.DatabaseChecker(conn),
}
}); err != nil {
return nil, err
}
return conn, nil
}
func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) {
config.SetDefault("storage.filesystem.basePath", "data")
config.SetDefault("storage.filesystem.capesDirName", "capes")
return fs.New(path.Join(
config.GetString("storage.filesystem.basePath"),
config.GetString("storage.filesystem.capesDirName"),
))
}
func newMojangSignedTexturesStorage() mojangtextures.TexturesStorage {
texturesStorage := mojangtextures.NewInMemoryTexturesStorage()
texturesStorage.Start()
return texturesStorage
}

20
di/di.go Normal file
View File

@@ -0,0 +1,20 @@
package di
import "github.com/goava/di"
func New() (*di.Container, error) {
container, err := di.New(
config,
dispatcher,
logger,
db,
mojangTextures,
handlers,
server,
)
if err != nil {
return nil, err
}
return container, nil
}

36
di/dispatcher.go Normal file
View File

@@ -0,0 +1,36 @@
package di
import (
"github.com/goava/di"
"github.com/mono83/slf"
d "github.com/elyby/chrly/dispatcher"
"github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var dispatcher = di.Options(
di.Provide(newDispatcher,
di.As(new(d.Emitter)),
di.As(new(d.Subscriber)),
di.As(new(http.Emitter)),
di.As(new(mojangtextures.Emitter)),
di.As(new(eventsubscribers.Subscriber)),
),
di.Invoke(enableEventsHandlers),
)
func newDispatcher() d.Dispatcher {
return d.New()
}
func enableEventsHandlers(
dispatcher d.Subscriber,
logger slf.Logger,
statsReporter slf.StatsReporter,
) {
// TODO: use idea from https://github.com/goava/di/issues/10#issuecomment-615869852
(&eventsubscribers.Logger{Logger: logger}).ConfigureWithDispatcher(dispatcher)
(&eventsubscribers.StatsReporter{StatsReporter: statsReporter}).ConfigureWithDispatcher(dispatcher)
}

151
di/handlers.go Normal file
View File

@@ -0,0 +1,151 @@
package di
import (
"net/http"
"strings"
"github.com/etherlabsio/healthcheck"
"github.com/goava/di"
"github.com/gorilla/mux"
"github.com/spf13/viper"
. "github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var handlers = di.Options(
di.Provide(newHandlerFactory, di.As(new(http.Handler))),
di.Provide(newSkinsystemHandler, di.WithName("skinsystem")),
di.Provide(newApiHandler, di.WithName("api")),
di.Provide(newUUIDsWorkerHandler, di.WithName("worker")),
)
func newHandlerFactory(
container *di.Container,
config *viper.Viper,
emitter Emitter,
) (*mux.Router, error) {
enabledModules := config.GetStringSlice("modules")
// gorilla.mux has no native way to combine multiple routers.
// The hack used later in the code works for prefixes in addresses, but leads to misbehavior
// if you set an empty prefix. Since the main application should be mounted at the root prefix,
// we use it as the base router
var router *mux.Router
if hasValue(enabledModules, "skinsystem") {
if err := container.Resolve(&router, di.Name("skinsystem")); err != nil {
return nil, err
}
} else {
router = mux.NewRouter()
}
router.StrictSlash(true)
requestEventsMiddleware := CreateRequestEventsMiddleware(emitter, "skinsystem")
router.Use(requestEventsMiddleware)
// NotFoundHandler doesn't call for registered middlewares, so we must wrap it manually.
// See https://github.com/gorilla/mux/issues/416#issuecomment-600079279
router.NotFoundHandler = requestEventsMiddleware(http.HandlerFunc(NotFoundHandler))
// Enable the worker module before api to allow gorilla.mux to correctly find the target router
// as it uses the first matching and /api overrides the more accurate /api/worker
if hasValue(enabledModules, "worker") {
var workerRouter *mux.Router
if err := container.Resolve(&workerRouter, di.Name("worker")); err != nil {
return nil, err
}
mount(router, "/api/worker", workerRouter)
}
if hasValue(enabledModules, "api") {
var apiRouter *mux.Router
if err := container.Resolve(&apiRouter, di.Name("api")); err != nil {
return nil, err
}
var authenticator Authenticator
if err := container.Resolve(&authenticator); err != nil {
return nil, err
}
apiRouter.Use(CreateAuthenticationMiddleware(authenticator))
mount(router, "/api", apiRouter)
}
// Resolve health checkers last, because all the services required by the application
// must first be initialized and each of them can publish its own checkers
var healthCheckers []*namedHealthChecker
if container.Has(&healthCheckers) {
if err := container.Resolve(&healthCheckers); err != nil {
return nil, err
}
checkersOptions := make([]healthcheck.Option, len(healthCheckers))
for i, checker := range healthCheckers {
checkersOptions[i] = healthcheck.WithChecker(checker.Name, checker.Checker)
}
router.Handle("/healthcheck", healthcheck.Handler(checkersOptions...)).Methods("GET")
}
return router, nil
}
func newSkinsystemHandler(
config *viper.Viper,
emitter Emitter,
skinsRepository SkinsRepository,
capesRepository CapesRepository,
mojangTexturesProvider MojangTexturesProvider,
) *mux.Router {
config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
return (&Skinsystem{
Emitter: emitter,
SkinsRepo: skinsRepository,
CapesRepo: capesRepository,
MojangTexturesProvider: mojangTexturesProvider,
TexturesExtraParamName: config.GetString("textures.extra_param_name"),
TexturesExtraParamValue: config.GetString("textures.extra_param_value"),
}).Handler()
}
func newApiHandler(emitter Emitter, skinsRepository SkinsRepository) *mux.Router {
return (&Api{
Emitter: emitter,
SkinsRepo: skinsRepository,
}).Handler()
}
func newUUIDsWorkerHandler(mojangUUIDsProvider *mojangtextures.BatchUuidsProvider) *mux.Router {
return (&UUIDsWorker{
MojangUuidsProvider: mojangUUIDsProvider,
}).Handler()
}
func hasValue(slice []string, needle string) bool {
for _, value := range slice {
if value == needle {
return true
}
}
return false
}
func mount(router *mux.Router, path string, handler http.Handler) {
router.PathPrefix(path).Handler(
http.StripPrefix(
strings.TrimSuffix(path, "/"),
handler,
),
)
}
type namedHealthChecker struct {
Name string
Checker healthcheck.Checker
}

97
di/logger.go Normal file
View File

@@ -0,0 +1,97 @@
package di
import (
"os"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"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/viper"
"github.com/elyby/chrly/version"
)
var logger = di.Options(
di.Provide(newLogger),
di.Provide(newSentry),
di.Provide(newStatsReporter),
)
type loggerParams struct {
di.Inject
SentryRaven *raven.Client `di:"" optional:"true"`
}
func newLogger(params loggerParams) slf.Logger {
dispatcher := &slf.Dispatcher{}
dispatcher.AddReceiver(writer.New(writer.Options{
Marker: false,
TimeFormat: "15:04:05.000",
}))
if params.SentryRaven != nil {
sentryReceiver, _ := sentry.NewReceiverWithCustomRaven(
params.SentryRaven,
&sentry.Config{
MinLevel: "warn",
},
)
dispatcher.AddReceiver(sentryReceiver)
}
logger := wd.Custom("", "", dispatcher)
logger.WithParams(rays.Host)
return logger
}
func newSentry(config *viper.Viper) (*raven.Client, error) {
sentryAddr := config.GetString("sentry.dsn")
if sentryAddr == "" {
return nil, nil
}
ravenClient, err := raven.New(sentryAddr)
if err != nil {
return nil, err
}
ravenClient.SetEnvironment("production")
ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver")
ravenClient.SetRelease(version.Version())
raven.DefaultClient = ravenClient
return ravenClient, nil
}
func newStatsReporter(config *viper.Viper) (slf.StatsReporter, error) {
dispatcher := &slf.Dispatcher{}
statsdAddr := config.GetString("statsd.addr")
if statsdAddr != "" {
hostname, err := os.Hostname()
if err != nil {
return nil, err
}
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
dispatcher.AddReceiver(statsdReceiver)
}
return wd.Custom("", "", dispatcher), nil
}

148
di/mojang_textures.go Normal file
View File

@@ -0,0 +1,148 @@
package di
import (
"fmt"
"net/url"
"time"
"github.com/goava/di"
"github.com/spf13/viper"
es "github.com/elyby/chrly/eventsubscribers"
"github.com/elyby/chrly/http"
"github.com/elyby/chrly/mojangtextures"
)
var mojangTextures = di.Options(
di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangTexturesRemoteUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider),
di.Provide(newMojangTexturesStorageFactory),
)
func newMojangTexturesProviderFactory(
container *di.Container,
config *viper.Viper,
) (http.MojangTexturesProvider, error) {
config.SetDefault("mojang_textures.enabled", true)
if !config.GetBool("mojang_textures.enabled") {
return &mojangtextures.NilProvider{}, nil
}
var provider *mojangtextures.Provider
err := container.Resolve(&provider)
if err != nil {
return nil, err
}
return provider, nil
}
func newMojangTexturesProvider(
emitter mojangtextures.Emitter,
uuidsProvider mojangtextures.UUIDsProvider,
texturesProvider mojangtextures.TexturesProvider,
storage mojangtextures.Storage,
) *mojangtextures.Provider {
return &mojangtextures.Provider{
Emitter: emitter,
UUIDsProvider: uuidsProvider,
TexturesProvider: texturesProvider,
Storage: storage,
}
}
func newMojangTexturesUuidsProviderFactory(
config *viper.Viper,
container *di.Container,
) (mojangtextures.UUIDsProvider, error) {
preferredUuidsProvider := config.GetString("mojang_textures.uuids_provider.driver")
if preferredUuidsProvider == "remote" {
var provider *mojangtextures.RemoteApiUuidsProvider
err := container.Resolve(&provider)
return provider, err
}
var provider *mojangtextures.BatchUuidsProvider
err := container.Resolve(&provider)
return provider, err
}
func newMojangTexturesBatchUUIDsProvider(
container *di.Container,
config *viper.Viper,
emitter mojangtextures.Emitter,
) (*mojangtextures.BatchUuidsProvider, error) {
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_batch_uuids_provider_cool_down_duration", time.Minute)
return &namedHealthChecker{
Name: "mojang-batch-uuids-provider-response",
Checker: es.MojangBatchUuidsProviderResponseChecker(
emitter,
config.GetDuration("healthcheck.mojang_batch_uuids_provider_cool_down_duration"),
),
}
}); err != nil {
return nil, err
}
if err := container.Provide(func(emitter es.Subscriber, config *viper.Viper) *namedHealthChecker {
config.SetDefault("healthcheck.mojang_batch_uuids_provider_queue_length_limit", 50)
return &namedHealthChecker{
Name: "mojang-batch-uuids-provider-queue-length",
Checker: es.MojangBatchUuidsProviderQueueLengthChecker(
emitter,
config.GetInt("healthcheck.mojang_batch_uuids_provider_queue_length_limit"),
),
}
}); err != nil {
return nil, err
}
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
return &mojangtextures.BatchUuidsProvider{
Emitter: emitter,
IterationDelay: config.GetDuration("queue.loop_delay"),
IterationSize: config.GetInt("queue.batch_size"),
}, nil
}
func newMojangTexturesRemoteUUIDsProvider(
config *viper.Viper,
emitter mojangtextures.Emitter,
) (*mojangtextures.RemoteApiUuidsProvider, error) {
remoteUrl, err := url.Parse(config.GetString("mojang_textures.uuids_provider.url"))
if err != nil {
return nil, fmt.Errorf("unable to parse remote url: %w", err)
}
return &mojangtextures.RemoteApiUuidsProvider{
Emitter: emitter,
Url: *remoteUrl,
}, nil
}
func newMojangSignedTexturesProvider(emitter mojangtextures.Emitter) mojangtextures.TexturesProvider {
return &mojangtextures.MojangApiTexturesProvider{
Emitter: emitter,
}
}
func newMojangTexturesStorageFactory(
uuidsStorage mojangtextures.UuidsStorage,
texturesStorage mojangtextures.TexturesStorage,
) mojangtextures.Storage {
return &mojangtextures.SeparatedStorage{
UuidsStorage: uuidsStorage,
TexturesStorage: texturesStorage,
}
}

65
di/server.go Normal file
View File

@@ -0,0 +1,65 @@
package di
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/getsentry/raven-go"
"github.com/goava/di"
"github.com/spf13/viper"
. "github.com/elyby/chrly/http"
)
var server = di.Options(
di.Provide(newAuthenticator, di.As(new(Authenticator))),
di.Provide(newServer),
)
func newAuthenticator(config *viper.Viper, emitter Emitter) (*JwtAuth, error) {
key := config.GetString("chrly.secret")
if key == "" {
return nil, errors.New("chrly.secret must be set in order to use authenticator")
}
return &JwtAuth{
Key: []byte(key),
Emitter: emitter,
}, nil
}
type serverParams struct {
di.Inject
Config *viper.Viper `di:""`
Handler http.Handler `di:""`
Sentry *raven.Client `di:"" optional:"true"`
}
func newServer(params serverParams) *http.Server {
params.Config.SetDefault("server.host", "")
params.Config.SetDefault("server.port", 80)
handler := params.Handler
if params.Sentry != nil {
// raven.Recoverer uses DefaultClient and nothing can be done about it
// To avoid code duplication, if the Sentry service is successfully initiated,
// it will also replace DefaultClient, so raven.Recoverer will work with the instance
// created in the application constructor
handler = raven.Recoverer(handler)
}
address := fmt.Sprintf("%s:%d", params.Config.GetString("server.host"), params.Config.GetInt("server.port"))
server := &http.Server{
Addr: address,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
return server
}

34
dispatcher/dispatcher.go Normal file
View File

@@ -0,0 +1,34 @@
package dispatcher
import "github.com/asaskevich/EventBus"
type Subscriber interface {
Subscribe(topic string, fn interface{})
}
type Emitter interface {
Emit(topic string, args ...interface{})
}
type Dispatcher interface {
Subscriber
Emitter
}
type localEventDispatcher struct {
bus EventBus.Bus
}
func (d *localEventDispatcher) Subscribe(topic string, fn interface{}) {
_ = d.bus.Subscribe(topic, fn)
}
func (d *localEventDispatcher) Emit(topic string, args ...interface{}) {
d.bus.Publish(topic, args...)
}
func New() Dispatcher {
return &localEventDispatcher{
bus: EventBus.New(),
}
}

14
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,14 @@
# This file can be used to start up necessary services.
# Copy it into the docker-compose.yml:
# > cp docker-compose.dev.yml docker-compose.yml
# And then run it:
# > docker-compose up -d
version: '2'
services:
redis:
image: redis:4.0-32bit
ports:
- "6379:6379"
volumes:
- ./data/redis:/data

36
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,36 @@
# This file can be used to run application in the production environment.
# Copy it into the docker-compose.yml:
# > cp docker-compose.prod.yml docker-compose.yml
# And then run it:
# > docker-compose up -d
# Service will be listened at the http://localhost
version: '2'
services:
app:
image: elyby/chrly
hostname: chrly0
restart: always
links:
- redis
volumes:
- ./data/capes:/data/capes
ports:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
# Use this configuration in case when you need a remote Mojang UUIDs provider
# worker:
# image: elyby/chrly
# hostname: chrly0
# restart: always
# ports:
# - "8080:80"
# command: ["worker"]
redis:
image: redis:4.0-32bit # 32-bit version is recommended to spare some memory
restart: always
volumes:
- ./data/redis:/data

12
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -e
if [ ! -d /data/capes ]; then
mkdir -p /data/capes
fi
if [ "$1" = "serve" ] || [ "$1" = "worker" ] || [ "$1" = "token" ] || [ "$1" = "version" ]; then
set -- /usr/local/bin/chrly "$@"
fi
exec "$@"

View File

@@ -0,0 +1,84 @@
package eventsubscribers
import (
"context"
"errors"
"sync"
"time"
"github.com/etherlabsio/healthcheck"
"github.com/elyby/chrly/api/mojang"
)
type Pingable interface {
Ping() error
}
func DatabaseChecker(connection Pingable) healthcheck.CheckerFunc {
return func(ctx context.Context) error {
done := make(chan error)
go func() {
done <- connection.Ping()
}()
select {
case <-ctx.Done():
return errors.New("check timeout")
case err := <-done:
return err
}
}
}
func MojangBatchUuidsProviderResponseChecker(dispatcher Subscriber, resetDuration time.Duration) healthcheck.CheckerFunc {
var mutex sync.Mutex
var lastCallErr error
var expireTimer *time.Timer
dispatcher.Subscribe(
"mojang_textures:batch_uuids_provider:result",
func(usernames []string, profiles []*mojang.ProfileInfo, err error) {
mutex.Lock()
defer mutex.Unlock()
lastCallErr = err
if expireTimer != nil {
expireTimer.Stop()
}
expireTimer = time.AfterFunc(resetDuration, func() {
mutex.Lock()
lastCallErr = nil
mutex.Unlock()
})
},
)
return func(ctx context.Context) error {
mutex.Lock()
defer mutex.Unlock()
return lastCallErr
}
}
func MojangBatchUuidsProviderQueueLengthChecker(dispatcher Subscriber, maxLength int) healthcheck.CheckerFunc {
var mutex sync.Mutex
queueLength := 0
dispatcher.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, tasksInQueue int) {
mutex.Lock()
queueLength = tasksInQueue
mutex.Unlock()
})
return func(ctx context.Context) error {
mutex.Lock()
defer mutex.Unlock()
if queueLength < maxLength {
return nil
}
return errors.New("the maximum number of tasks in the queue has been exceeded")
}
}

View File

@@ -0,0 +1,109 @@
package eventsubscribers
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type pingableMock struct {
mock.Mock
}
func (p *pingableMock) Ping() error {
args := p.Called()
return args.Error(0)
}
func TestDatabaseChecker(t *testing.T) {
t.Run("no error", func(t *testing.T) {
p := &pingableMock{}
p.On("Ping").Return(nil)
checker := DatabaseChecker(p)
assert.Nil(t, checker(context.Background()))
})
t.Run("with error", func(t *testing.T) {
err := errors.New("mock error")
p := &pingableMock{}
p.On("Ping").Return(err)
checker := DatabaseChecker(p)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("context timeout", func(t *testing.T) {
p := &pingableMock{}
waitChan := make(chan time.Time, 1)
p.On("Ping").WaitUntil(waitChan).Return(nil)
ctx, _ := context.WithTimeout(context.Background(), 0)
checker := DatabaseChecker(p)
assert.Errorf(t, checker(ctx), "check timeout")
close(waitChan)
})
}
func TestMojangBatchUuidsProviderChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
//
t.Run("when no error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, []*mojang.ProfileInfo{}, nil)
assert.Nil(t, checker(context.Background()))
})
t.Run("when error occurred", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
assert.Equal(t, err, checker(context.Background()))
})
t.Run("should reset value after passed duration", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderResponseChecker(d, 20*time.Millisecond)
err := errors.New("some error occurred")
d.Emit("mojang_textures:batch_uuids_provider:result", []string{"username"}, nil, err)
assert.Equal(t, err, checker(context.Background()))
time.Sleep(40 * time.Millisecond)
assert.Nil(t, checker(context.Background()))
})
}
func TestMojangBatchUuidsProviderQueueLengthChecker(t *testing.T) {
t.Run("empty state", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
assert.Nil(t, checker(context.Background()))
})
t.Run("less than allowed limit", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 9)
assert.Nil(t, checker(context.Background()))
})
t.Run("greater than allowed limit", func(t *testing.T) {
d := dispatcher.New()
checker := MojangBatchUuidsProviderQueueLengthChecker(d, 10)
d.Emit("mojang_textures:batch_uuids_provider:round", []string{"username"}, 10)
checkResult := checker(context.Background())
if assert.Error(t, checkResult) {
assert.Equal(t, "the maximum number of tasks in the queue has been exceeded", checkResult.Error())
}
})
}

View File

@@ -0,0 +1,94 @@
package eventsubscribers
import (
"net"
"net/http"
"net/url"
"strings"
"syscall"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/api/mojang"
)
type Logger struct {
slf.Logger
}
func (l *Logger) ConfigureWithDispatcher(d Subscriber) {
d.Subscribe("skinsystem:after_request", l.handleAfterSkinsystemRequest)
d.Subscribe("mojang_textures:usernames:after_call", l.createMojangTexturesErrorHandler("usernames"))
d.Subscribe("mojang_textures:textures:after_call", l.createMojangTexturesErrorHandler("textures"))
}
func (l *Logger) handleAfterSkinsystemRequest(req *http.Request, statusCode int) {
path := req.URL.Path
if req.URL.RawQuery != "" {
path += "?" + req.URL.RawQuery
}
l.Info(
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
wd.StringParam("ip", trimPort(req.RemoteAddr)),
wd.StringParam("method", req.Method),
wd.StringParam("path", path),
wd.IntParam("statusCode", statusCode),
wd.StringParam("userAgent", req.UserAgent()),
wd.StringParam("forwardedIp", req.Header.Get("X-Forwarded-For")),
)
}
func (l *Logger) createMojangTexturesErrorHandler(provider string) func(identity string, result interface{}, err error) {
providerParam := wd.NameParam(provider)
return func(identity string, result interface{}, err error) {
if err == nil {
return
}
errParam := wd.ErrParam(err)
switch err.(type) {
case *mojang.BadRequestError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case *mojang.ForbiddenError:
l.logMojangTexturesWarning(providerParam, errParam)
return
case *mojang.TooManyRequestsError:
l.logMojangTexturesWarning(providerParam, errParam)
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
}
}
l.Error(":name: Unexpected Mojang response error: :err", providerParam, errParam)
}
}
func (l *Logger) logMojangTexturesWarning(providerParam slf.Param, errParam slf.Param) {
l.Warning(":name: :err", providerParam, errParam)
}
func trimPort(ip string) string {
// Don't care about possible -1 result because RemoteAddr will always contain ip and port
cutTo := strings.LastIndexByte(ip, ':')
return ip[0:cutTo]
}

View File

@@ -0,0 +1,256 @@
package eventsubscribers
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"syscall"
"testing"
"github.com/mono83/slf"
"github.com/mono83/slf/params"
"github.com/stretchr/testify/mock"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type LoggerMock struct {
mock.Mock
}
func prepareLoggerArgs(message string, params []slf.Param) []interface{} {
args := []interface{}{message}
for _, v := range params {
args = append(args, v.(interface{}))
}
return args
}
func (l *LoggerMock) Trace(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Debug(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Info(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Warning(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Error(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Alert(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
func (l *LoggerMock) Emergency(message string, params ...slf.Param) {
l.Called(prepareLoggerArgs(message, params)...)
}
type LoggerTestCase struct {
Events [][]interface{}
ExpectedCalls [][]interface{}
}
var loggerTestCases = map[string]*LoggerTestCase{
"should log each request to the skinsystem": {
Events: [][]interface{}{
{"skinsystem:after_request",
(func() *http.Request {
req := httptest.NewRequest("GET", "http://localhost/skins/username.png", nil)
req.Header.Add("User-Agent", "Test user agent")
return req
})(),
201,
},
},
ExpectedCalls: [][]interface{}{
{"Info",
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "ip" && strParam.Value == "192.0.2.1"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "method" && strParam.Value == "GET"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "path" && strParam.Value == "/skins/username.png"
}),
mock.MatchedBy(func(strParam params.Int) bool {
return strParam.Key == "statusCode" && strParam.Value == 201
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "userAgent" && strParam.Value == "Test user agent"
}),
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "forwardedIp" && strParam.Value == ""
}),
},
},
},
"should log each request to the skinsystem 2": {
Events: [][]interface{}{
{"skinsystem:after_request",
(func() *http.Request {
req := httptest.NewRequest("GET", "http://localhost/skins/username.png?authlib=1.5.2", nil)
req.Header.Add("User-Agent", "Test user agent")
req.Header.Add("X-Forwarded-For", "1.2.3.4")
return req
})(),
201,
},
},
ExpectedCalls: [][]interface{}{
{"Info",
":ip - - \":method :path\" :statusCode - \":userAgent\" \":forwardedIp\"",
mock.Anything, // Already tested
mock.Anything, // Already tested
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "path" && strParam.Value == "/skins/username.png?authlib=1.5.2"
}),
mock.Anything, // Already tested
mock.Anything, // Already tested
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "forwardedIp" && strParam.Value == "1.2.3.4"
}),
},
},
},
}
type timeoutError struct{}
func (*timeoutError) Error() string { return "timeout error" }
func (*timeoutError) Timeout() bool { return true }
func (*timeoutError) Temporary() bool { return false }
func init() {
// mojang_textures providers errors
for _, providerName := range []string{"usernames", "textures"} {
pn := providerName // Store pointer to iteration value
loggerTestCases["should not log when no error occurred for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, &mojang.ProfileInfo{}, nil},
},
ExpectedCalls: nil,
}
loggerTestCases["should not log when some network errors occured for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &timeoutError{}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &url.Error{Op: "GET", URL: "http://localhost"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "read"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &net.OpError{Op: "dial"}},
{"mojang_textures:" + pn + ":after_call", pn, nil, syscall.ECONNREFUSED},
},
ExpectedCalls: nil,
}
loggerTestCases["should log expected mojang errors for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.BadRequestError{
ErrorType: "IllegalArgumentException",
Message: "profileName can not be null or empty.",
}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ForbiddenError{}},
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.TooManyRequestsError{}},
},
ExpectedCalls: [][]interface{}{
{"Warning",
":name: :err",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "name" && strParam.Value == pn
}),
mock.MatchedBy(func(errParam params.Error) bool {
if errParam.Key != "err" {
return false
}
if _, ok := errParam.Value.(*mojang.BadRequestError); ok {
return true
}
if _, ok := errParam.Value.(*mojang.ForbiddenError); ok {
return true
}
if _, ok := errParam.Value.(*mojang.TooManyRequestsError); ok {
return true
}
return false
}),
},
},
}
loggerTestCases["should call error when unexpected error occurred for "+pn+" provider"] = &LoggerTestCase{
Events: [][]interface{}{
{"mojang_textures:" + pn + ":after_call", pn, nil, &mojang.ServerError{Status: 500}},
},
ExpectedCalls: [][]interface{}{
{"Error",
":name: Unexpected Mojang response error: :err",
mock.MatchedBy(func(strParam params.String) bool {
return strParam.Key == "name" && strParam.Value == pn
}),
mock.MatchedBy(func(errParam params.Error) bool {
if errParam.Key != "err" {
return false
}
if _, ok := errParam.Value.(*mojang.ServerError); !ok {
return false
}
return true
}),
},
},
}
}
}
func TestLogger(t *testing.T) {
for name, c := range loggerTestCases {
t.Run(name, func(t *testing.T) {
loggerMock := &LoggerMock{}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
loggerMock.On(topicName, c[1:]...)
}
}
reporter := &Logger{
Logger: loggerMock,
}
d := dispatcher.New()
reporter.ConfigureWithDispatcher(d)
for _, args := range c.Events {
eventName, _ := args[0].(string)
d.Emit(eventName, args[1:]...)
}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
loggerMock.AssertCalled(t, topicName, c[1:]...)
}
}
})
}
}

View File

@@ -0,0 +1,177 @@
package eventsubscribers
import (
"net/http"
"strings"
"sync"
"time"
"github.com/mono83/slf"
"github.com/elyby/chrly/api/mojang"
)
type StatsReporter struct {
slf.StatsReporter
Prefix string
timersMap map[string]time.Time
timersMutex sync.Mutex
}
func (s *StatsReporter) ConfigureWithDispatcher(d Subscriber) {
s.timersMap = make(map[string]time.Time)
// Per request events
d.Subscribe("skinsystem:before_request", s.handleBeforeRequest)
d.Subscribe("skinsystem:after_request", s.handleAfterRequest)
// Authentication events
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
d.Subscribe("authenticator:success", s.incCounterHandler("authentication.success"))
d.Subscribe("authentication:error", s.incCounterHandler("authentication.challenge")) // TODO: legacy, remove in v5
d.Subscribe("authentication:error", s.incCounterHandler("authentication.failed"))
// Mojang signed textures source events
d.Subscribe("mojang_textures:call", s.incCounterHandler("mojang_textures.request"))
d.Subscribe("mojang_textures:usernames:after_cache", func(username string, uuid string, err error) {
if err != nil {
return
}
if uuid == "" {
s.IncCounter("mojang_textures:usernames:cache_hit_nil", 1)
} else {
s.IncCounter("mojang_textures:usernames:cache_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:after_cache", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
if err != nil {
return
}
if textures != nil {
s.IncCounter("mojang_textures.textures.cache_hit", 1)
}
})
d.Subscribe("mojang_textures:already_processing", s.incCounterHandler("mojang_textures.already_scheduled"))
d.Subscribe("mojang_textures:usernames:after_call", func(username string, profile *mojang.ProfileInfo, err error) {
if err != nil {
return
}
if profile == nil {
s.IncCounter("mojang_textures.usernames.uuid_miss", 1)
} else {
s.IncCounter("mojang_textures.usernames.uuid_hit", 1)
}
})
d.Subscribe("mojang_textures:textures:before_call", s.incCounterHandler("mojang_textures.textures.request"))
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
if err != nil {
return
}
if textures == nil {
s.IncCounter("mojang_textures.usernames.textures_miss", 1)
} else {
s.IncCounter("mojang_textures.usernames.textures_hit", 1)
}
})
d.Subscribe("mojang_textures:before_result", func(username string, uuid string) {
s.startTimeRecording("mojang_textures_result_time_" + username)
})
d.Subscribe("mojang_textures:after_result", func(username string, textures *mojang.SignedTexturesResponse, err error) {
s.finalizeTimeRecording("mojang_textures_result_time_"+username, "mojang_textures.result_time")
})
d.Subscribe("mojang_textures:textures:before_call", func(uuid string) {
s.startTimeRecording("mojang_textures_provider_time_" + uuid)
})
d.Subscribe("mojang_textures:textures:after_call", func(uuid string, textures *mojang.SignedTexturesResponse, err error) {
s.finalizeTimeRecording("mojang_textures_provider_time_"+uuid, "mojang_textures.textures.request_time")
})
// Mojang UUIDs batch provider metrics
d.Subscribe("mojang_textures:batch_uuids_provider:queued", s.incCounterHandler("mojang_textures.usernames.queued"))
d.Subscribe("mojang_textures:batch_uuids_provider:round", func(usernames []string, queueSize int) {
s.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(usernames)))
s.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize))
})
d.Subscribe("mojang_textures:batch_uuids_provider:before_round", func() {
s.startTimeRecording("batch_uuids_provider_round_time")
})
d.Subscribe("mojang_textures:batch_uuids_provider:after_round", func() {
s.finalizeTimeRecording("batch_uuids_provider_round_time", "mojang_textures.usernames.round_time")
})
}
func (s *StatsReporter) handleBeforeRequest(req *http.Request) {
var key string
m := req.Method
p := req.URL.Path
if p == "/skins" {
key = "skins.get_request"
} else if strings.HasPrefix(p, "/skins/") {
key = "skins.request"
} else if p == "/cloaks" {
key = "capes.get_request"
} else if strings.HasPrefix(p, "/cloaks/") {
key = "capes.request"
} else if strings.HasPrefix(p, "/textures/signed/") {
key = "signed_textures.request"
} else if strings.HasPrefix(p, "/textures/") {
key = "textures.request"
} else if m == http.MethodPost && p == "/api/skins" {
key = "api.skins.post.request"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") {
key = "api.skins.delete.request"
} else {
return
}
s.IncCounter(key, 1)
}
func (s *StatsReporter) handleAfterRequest(req *http.Request, code int) {
var key string
m := req.Method
p := req.URL.Path
if m == http.MethodPost && p == "/api/skins" && code == http.StatusCreated {
key = "api.skins.post.success"
} else if m == http.MethodPost && p == "/api/skins" && code == http.StatusBadRequest {
key = "api.skins.post.validation_failed"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNoContent {
key = "api.skins.delete.success"
} else if m == http.MethodDelete && strings.HasPrefix(p, "/api/skins/") && code == http.StatusNotFound {
key = "api.skins.delete.not_found"
} else {
return
}
s.IncCounter(key, 1)
}
func (s *StatsReporter) incCounterHandler(name string) func(...interface{}) {
return func(...interface{}) {
s.IncCounter(name, 1)
}
}
func (s *StatsReporter) startTimeRecording(timeKey string) {
s.timersMutex.Lock()
defer s.timersMutex.Unlock()
s.timersMap[timeKey] = time.Now()
}
func (s *StatsReporter) finalizeTimeRecording(timeKey string, statName string) {
s.timersMutex.Lock()
defer s.timersMutex.Unlock()
startedAt, ok := s.timersMap[timeKey]
if !ok {
return
}
delete(s.timersMap, timeKey)
s.RecordTimer(statName, time.Since(startedAt))
}

View File

@@ -0,0 +1,383 @@
package eventsubscribers
import (
"errors"
"net/http/httptest"
"testing"
"time"
"github.com/mono83/slf"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
"github.com/stretchr/testify/mock"
)
func prepareStatsReporterArgs(name string, value interface{}, params []slf.Param) []interface{} {
args := []interface{}{name, value}
for _, v := range params {
args = append(args, v.(interface{}))
}
return args
}
type StatsReporterMock struct {
mock.Mock
}
func (r *StatsReporterMock) IncCounter(name string, value int64, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, value, params)...)
}
func (r *StatsReporterMock) UpdateGauge(name string, value int64, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, value, params)...)
}
func (r *StatsReporterMock) RecordTimer(name string, duration time.Duration, params ...slf.Param) {
r.Called(prepareStatsReporterArgs(name, duration, params)...)
}
func (r *StatsReporterMock) Timer(name string, params ...slf.Param) slf.Timer {
return slf.NewTimer(name, params, r)
}
type StatsReporterTestCase struct {
Events [][]interface{}
ExpectedCalls [][]interface{}
}
var statsReporterTestCases = []*StatsReporterTestCase{
// Before request
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/skins/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "skins.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/skins?name=username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "skins.get_request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/cloaks/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "capes.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/cloaks?name=username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "capes.get_request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/textures/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/textures/signed/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "signed_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil)},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:before_request", httptest.NewRequest("GET", "http://localhost/unknown", nil)},
},
ExpectedCalls: nil,
},
// After request
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil), 201},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("POST", "http://localhost/api/skins", nil), 400},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.post.validation_failed", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil), 204},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/username", nil), 404},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.not_found", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil), 204},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/api/skins/id:1", nil), 404},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "api.skins.delete.not_found", int64(1)},
},
},
{
Events: [][]interface{}{
{"skinsystem:after_request", httptest.NewRequest("DELETE", "http://localhost/unknown", nil), 404},
},
ExpectedCalls: nil,
},
// Authenticator
{
Events: [][]interface{}{
{"authenticator:success"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "authentication.challenge", int64(1)},
{"IncCounter", "authentication.success", int64(1)},
},
},
{
Events: [][]interface{}{
{"authentication:error", errors.New("error")},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "authentication.challenge", int64(1)},
{"IncCounter", "authentication.failed", int64(1)},
},
},
// Mojang signed textures provider
{
Events: [][]interface{}{
{"mojang_textures:call", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.request", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "", nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit_nil", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures:usernames:cache_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.textures.cache_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:already_processing", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.already_scheduled", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", nil, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:usernames:after_call", "username", &mojang.ProfileInfo{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, errors.New("error")},
},
ExpectedCalls: [][]interface{}{},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.textures_miss", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:before_result", "username", ""},
{"mojang_textures:after_result", "username", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.result_time", mock.AnythingOfType("time.Duration")},
},
},
{
Events: [][]interface{}{
{"mojang_textures:textures:before_call", "аааааааааааааааааааааааааааааааа"},
{"mojang_textures:textures:after_call", "аааааааааааааааааааааааааааааааа", &mojang.SignedTexturesResponse{}, nil},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.textures.request", int64(1)},
{"IncCounter", "mojang_textures.usernames.textures_hit", int64(1)},
{"RecordTimer", "mojang_textures.textures.request_time", mock.AnythingOfType("time.Duration")},
},
},
// Batch UUIDs provider
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:queued", "username"},
},
ExpectedCalls: [][]interface{}{
{"IncCounter", "mojang_textures.usernames.queued", int64(1)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:round", []string{"username1", "username2"}, 5},
},
ExpectedCalls: [][]interface{}{
{"UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)},
{"UpdateGauge", "mojang_textures.usernames.queue_size", int64(5)},
},
},
{
Events: [][]interface{}{
{"mojang_textures:batch_uuids_provider:before_round"},
{"mojang_textures:batch_uuids_provider:after_round"},
},
ExpectedCalls: [][]interface{}{
{"RecordTimer", "mojang_textures.usernames.round_time", mock.AnythingOfType("time.Duration")},
},
},
}
func TestStatsReporter(t *testing.T) {
for _, c := range statsReporterTestCases {
t.Run("handle events", func(t *testing.T) {
statsReporterMock := &StatsReporterMock{}
if c.ExpectedCalls != nil {
for _, c := range c.ExpectedCalls {
topicName, _ := c[0].(string)
statsReporterMock.On(topicName, c[1:]...).Once()
}
}
reporter := &StatsReporter{
StatsReporter: statsReporterMock,
Prefix: "mock_prefix",
}
d := dispatcher.New()
reporter.ConfigureWithDispatcher(d)
for _, e := range c.Events {
eventName, _ := e[0].(string)
d.Emit(eventName, e[1:]...)
}
statsReporterMock.AssertExpectations(t)
})
}
}

View File

@@ -0,0 +1,7 @@
package eventsubscribers
import "github.com/elyby/chrly/dispatcher"
type Subscriber interface {
dispatcher.Subscriber
}

225
http/api.go Normal file
View File

@@ -0,0 +1,225 @@
package http
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"github.com/gorilla/mux"
"github.com/thedevsaddam/govalidator"
"github.com/elyby/chrly/model"
)
//noinspection GoSnakeCaseUsage
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
var regexUuidAny = regexp.MustCompile(UUID_ANY)
func init() {
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
if message == "" {
message = "Skin uploading is temporary unavailable"
}
return errors.New(message)
})
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
if !regexUuidAny.MatchString(str) {
if message == "" {
message = fmt.Sprintf("The %s field must contain valid UUID", field)
}
return errors.New(message)
}
return nil
})
}
type Api struct {
Emitter
SkinsRepo SkinsRepository
}
func (ctx *Api) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost)
router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete)
router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete)
return router
}
func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) {
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
apiBadRequest(resp, validationErrors)
return
}
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
username := req.Form.Get("username")
record, err := ctx.findIdentityOrCleanup(identityId, username)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("error on requesting a skin from the repository: %w", err))
apiServerError(resp)
return
}
if record == nil {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
record.Uuid = req.Form.Get("uuid")
record.SkinId = skinId
record.Is1_8 = is18
record.IsSlim = isSlim
record.Url = req.Form.Get("url")
record.MojangTextures = req.Form.Get("mojangTextures")
record.MojangSignature = req.Form.Get("mojangSignature")
err = ctx.SkinsRepo.SaveSkin(record)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to save record to the repository: %w", err))
apiServerError(resp)
return
}
resp.WriteHeader(http.StatusCreated)
}
func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) {
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := ctx.SkinsRepo.FindSkinByUserId(id)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) {
username := mux.Vars(req)["username"]
skin, err := ctx.SkinsRepo.FindSkinByUsername(username)
ctx.deleteSkin(skin, err, resp)
}
func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) {
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("unable to find skin info from the repository: %w", err))
apiServerError(resp)
return
}
if skin == nil {
apiNotFound(resp, "Cannot find record for the requested identifier")
return
}
err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId)
if err != nil {
ctx.Emit("skinsystem:error", fmt.Errorf("cannot delete skin by error: %w", err))
apiServerError(resp)
return
}
resp.WriteHeader(http.StatusNoContent)
}
func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) {
record, err := ctx.SkinsRepo.FindSkinByUserId(identityId)
if err != nil {
return nil, err
}
if record != nil {
// The username may have changed in the external database,
// so we need to remove the old association
if record.Username != username {
_ = ctx.SkinsRepo.RemoveSkinByUserId(identityId)
record.Username = username
}
return record, nil
}
// If the requested id was not found, then username was reassigned to another user
// who has not uploaded his data to Chrly yet
record, err = ctx.SkinsRepo.FindSkinByUsername(username)
if err != nil {
return nil, err
}
// If the target username does exist, clear it as it will be reassigned to the new user
if record != nil {
_ = ctx.SkinsRepo.RemoveSkinByUsername(username)
record.UserId = identityId
return record, nil
}
return nil, nil
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
_ = request.ParseMultipartForm(maxMultipartMemory)
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"is1_8": {"bool"},
"isSlim": {"bool"},
}
shouldAppendSkinRequiredError := false
url := request.Form.Get("url")
_, _, skinErr := request.FormFile("skin")
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
shouldAppendSkinRequiredError = true
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = []string{"required"}
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
FormSize: maxMultipartMemory,
})
validationResults := validator.Validate()
if shouldAppendSkinRequiredError {
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
}
if len(validationResults) != 0 {
return validationResults
}
return nil
}

442
http/api_test.go Normal file
View File

@@ -0,0 +1,442 @@
package http
import (
"bytes"
"encoding/base64"
"errors"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/model"
)
/***************
* Setup mocks *
***************/
type apiTestSuite struct {
suite.Suite
App *Api
SkinsRepository *skinsRepositoryMock
Emitter *emitterMock
}
/********************
* Setup test suite *
********************/
func (suite *apiTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.Emitter = &emitterMock{}
suite.App = &Api{
SkinsRepo: suite.SkinsRepository,
Emitter: suite.Emitter,
}
}
func (suite *apiTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *apiTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestApi(t *testing.T) {
suite.Run(t, new(apiTestSuite))
}
/*************************
* Post skin tests cases *
*************************/
type postSkinTestCase struct {
Name string
Form io.Reader
BeforeTest func(suite *apiTestSuite)
AfterTest func(suite *apiTestSuite, response *http.Response)
}
var postSkinTestsCases = []*postSkinTestCase{
{
Name: "Upload new identity with textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.False(model.Is1_8)
suite.False(model.IsSlim)
suite.Equal("http://example.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing only textures data",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
suite.Equal(5, model.SkinId)
suite.True(model.Is1_8)
suite.True(model.IsSlim)
suite.Equal("http://textures-server.com/skin.png", model.Url)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its identityId",
Form: bytes.NewBufferString(url.Values{
"identityId": {"2"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 2).Return(nil, nil)
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUsername", "mock_username").Times(1).Return(nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(2, model.UserId)
suite.Equal("mock_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Update exists identity by changing its username",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Times(1).Return(nil)
suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool {
suite.Equal(1, model.UserId)
suite.Equal("changed_username", model.Username)
suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid)
return true
})).Times(1).Return(nil)
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(201, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when loading the data from the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"mock_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"1"},
"isSlim": {"1"},
"url": {"http://textures-server.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
err := errors.New("mock error")
suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "unable to save record to the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
{
Name: "Handle an error when saving the data into the repository",
Form: bytes.NewBufferString(url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://example.com/skin.png"},
}.Encode()),
BeforeTest: func(suite *apiTestSuite) {
err := errors.New("mock error")
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, err)
suite.Emitter.On("Emit", "skinsystem:error", mock.MatchedBy(func(cErr error) bool {
return cErr.Error() == "error on requesting a skin from the repository: mock error" &&
errors.Is(cErr, err)
})).Once()
},
AfterTest: func(suite *apiTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
}
func (suite *apiTestSuite) TestPostSkin() {
for _, testCase := range postSkinTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Get errors about required fields", func() {
req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{
"mojangTextures": {"someBase64EncodedString"},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.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(body))
})
suite.RunSubTest("Upload textures with skin as file", func() {
inputBody := &bytes.Buffer{}
writer := multipart.NewWriter(inputBody)
part, _ := writer.CreateFormFile("skin", "char.png")
_, _ = part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://chrly/skins", inputBody)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(400, resp.StatusCode)
responseBody, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(responseBody))
})
}
/**************************************
* Delete skin by user id tests cases *
**************************************/
func (suite *apiTestSuite) TestDeleteByUserId() {
suite.RunSubTest("Delete skin by its identity id", func() {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity id", func() {
suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
/***************************************
* Delete skin by username tests cases *
***************************************/
func (suite *apiTestSuite) TestDeleteByUsername() {
suite.RunSubTest("Delete skin by its identity username", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(204, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.Empty(body)
})
suite.RunSubTest("Try to remove not exists identity username", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
suite.Equal(404, resp.StatusCode)
body, _ := ioutil.ReadAll(resp.Body)
suite.JSONEq(`[
"Cannot find record for the requested identifier"
]`, string(body))
})
}
/*************
* Utilities *
*************/
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
func loadSkinFile() []byte {
result := make([]byte, 92)
_, err := base64.StdEncoding.Decode(result, OnePxPng)
if err != nil {
panic(err)
}
return result
}

141
http/http.go Normal file
View File

@@ -0,0 +1,141 @@
package http
import (
"context"
"encoding/json"
"net/http"
"os"
"os/signal"
"strings"
"github.com/gorilla/mux"
"github.com/mono83/slf"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/dispatcher"
v "github.com/elyby/chrly/version"
)
type Emitter interface {
dispatcher.Emitter
}
func StartServer(server *http.Server, logger slf.Logger) {
logger.Debug("Chrly :v (:c)", wd.StringParam("v", v.Version()), wd.StringParam("c", v.Commit()))
done := make(chan bool, 1)
go func() {
logger.Info("Starting the server, HTTP on: :addr", wd.StringParam("addr", server.Addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Emergency("Error in main(): :err", wd.ErrParam(err))
}
close(done)
}()
go func() {
s := waitForExitSignal()
logger.Info("Got signal: :signal, starting graceful shutdown", wd.StringParam("signal", s.String()))
server.Shutdown(context.Background())
logger.Info("Graceful shutdown succeed, exiting", wd.StringParam("signal", s.String()))
close(done)
}()
<-done
}
func waitForExitSignal() os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, os.Kill)
return <-ch
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func CreateRequestEventsMiddleware(emitter Emitter, prefix string) mux.MiddlewareFunc {
beforeTopic := strings.Join([]string{prefix, "before_request"}, ":")
afterTopic := strings.Join([]string{prefix, "after_request"}, ":")
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
emitter.Emit(beforeTopic, req)
lrw := &loggingResponseWriter{
ResponseWriter: resp,
statusCode: http.StatusOK,
}
handler.ServeHTTP(lrw, req)
emitter.Emit(afterTopic, req, lrw.statusCode)
})
}
}
type Authenticator interface {
Authenticate(req *http.Request) error
}
func CreateAuthenticationMiddleware(checker Authenticator) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
err := checker.Authenticate(req)
if err != nil {
apiForbidden(resp, err.Error())
return
}
handler.ServeHTTP(resp, req)
})
}
}
func NotFoundHandler(response http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",
"message": "Not Found",
})
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusNotFound)
_, _ = response.Write(data)
}
func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) {
resp.WriteHeader(http.StatusBadRequest)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"errors": errorsPerField,
})
_, _ = resp.Write(result)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"error": reason,
})
_, _ = resp.Write(result)
}
func apiNotFound(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusNotFound)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal([]interface{}{
reason,
})
_, _ = resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

112
http/http_test.go Normal file
View File

@@ -0,0 +1,112 @@
package http
import (
"errors"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type emitterMock struct {
mock.Mock
}
func (e *emitterMock) Emit(name string, args ...interface{}) {
e.Called(append([]interface{}{name}, args...)...)
}
func TestCreateRequestEventsMiddleware(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
emitter := &emitterMock{}
emitter.On("Emit", "test_prefix:before_request", req)
emitter.On("Emit", "test_prefix:after_request", req, 400)
isHandlerCalled := false
middlewareFunc := CreateRequestEventsMiddleware(emitter, "test_prefix")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
resp.WriteHeader(400)
isHandlerCalled = true
})).ServeHTTP(resp, req)
if !isHandlerCalled {
t.Fatal("Handler isn't called from the middleware")
}
emitter.AssertExpectations(t)
}
type authCheckerMock struct {
mock.Mock
}
func (m *authCheckerMock) Authenticate(req *http.Request) error {
args := m.Called(req)
return args.Error(0)
}
func TestCreateAuthenticationMiddleware(t *testing.T) {
t.Run("pass", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(nil)
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.True(t, isHandlerCalled, "Handler isn't called from the middleware")
auth.AssertExpectations(t)
})
t.Run("fail", func(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req).Once().Return(errors.New("error reason"))
isHandlerCalled := false
middlewareFunc := CreateAuthenticationMiddleware(auth)
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isHandlerCalled, "Handler shouldn't be called")
testify.Equal(t, 403, resp.Code)
body, _ := ioutil.ReadAll(resp.Body)
testify.JSONEq(t, `{
"error": "error reason"
}`, string(body))
auth.AssertExpectations(t)
})
}
func TestNotFoundHandler(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://example.com", nil)
w := httptest.NewRecorder()
NotFoundHandler(w, req)
resp := w.Result()
assert.Equal(404, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"status": "404",
"message": "Not Found"
}`, string(response))
}

78
http/jwt.go Normal file
View File

@@ -0,0 +1,78 @@
package http
import (
"errors"
"net/http"
"strings"
"time"
"github.com/SermoDigital/jose/crypto"
"github.com/SermoDigital/jose/jws"
)
var hashAlg = crypto.SigningMethodHS256
const scopesClaim = "scopes"
type Scope string
var (
SkinScope = Scope("skin")
)
type JwtAuth struct {
Emitter
Key []byte
}
func (t *JwtAuth) NewToken(scopes ...Scope) ([]byte, error) {
if len(t.Key) == 0 {
return nil, errors.New("signing key not available")
}
claims := jws.Claims{}
claims.Set(scopesClaim, scopes)
claims.SetIssuedAt(time.Now())
encoder := jws.NewJWT(claims, hashAlg)
token, err := encoder.Serialize(t.Key)
if err != nil {
return nil, err
}
return token, nil
}
func (t *JwtAuth) Authenticate(req *http.Request) error {
if len(t.Key) == 0 {
return t.emitErr(errors.New("Signing key not set"))
}
bearerToken := req.Header.Get("Authorization")
if bearerToken == "" {
return t.emitErr(errors.New("Authentication header not presented"))
}
if !strings.EqualFold(bearerToken[0:7], "BEARER ") {
return t.emitErr(errors.New("Cannot recognize JWT token in passed value"))
}
tokenStr := bearerToken[7:]
token, err := jws.ParseJWT([]byte(tokenStr))
if err != nil {
return t.emitErr(errors.New("Cannot parse passed JWT token"))
}
err = token.Validate(t.Key, hashAlg)
if err != nil {
return t.emitErr(errors.New("JWT token have invalid signature. It may be corrupted or expired"))
}
t.Emit("authentication:success")
return nil
}
func (t *JwtAuth) emitErr(err error) error {
t.Emit("authentication:error", err)
return err
}

127
http/jwt_test.go Normal file
View File

@@ -0,0 +1,127 @@
package http
import (
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8"
func TestJwtAuth_NewToken(t *testing.T) {
t.Run("success", func(t *testing.T) {
jwt := &JwtAuth{Key: []byte("secret")}
token, err := jwt.NewToken(SkinScope)
assert.Nil(t, err)
assert.NotNil(t, token)
})
t.Run("key not provided", func(t *testing.T) {
jwt := &JwtAuth{}
token, err := jwt.NewToken(SkinScope)
assert.Error(t, err, "signing key not available")
assert.Nil(t, token)
})
}
func TestJwtAuth_Authenticate(t *testing.T) {
t.Run("success", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:success")
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Nil(t, err)
emitter.AssertExpectations(t)
})
t.Run("request without auth header", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Authentication header not presented")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Authentication header not presented")
emitter.AssertExpectations(t)
})
t.Run("no bearer token prefix", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Cannot recognize JWT token in passed value")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "this is not jwt")
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Cannot recognize JWT token in passed value")
emitter.AssertExpectations(t)
})
t.Run("bearer token but not jwt", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Cannot parse passed JWT token")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt")
jwt := &JwtAuth{Key: []byte("secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Cannot parse passed JWT token")
emitter.AssertExpectations(t)
})
t.Run("when secret is not set", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "Signing key not set")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "Signing key not set")
emitter.AssertExpectations(t)
})
t.Run("invalid signature", func(t *testing.T) {
emitter := &emitterMock{}
emitter.On("Emit", "authentication:error", mock.MatchedBy(func(err error) bool {
assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired")
return true
}))
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{Key: []byte("this is another secret"), Emitter: emitter}
err := jwt.Authenticate(req)
assert.Error(t, err, "JWT token have invalid signature. It may be corrupted or expired")
emitter.AssertExpectations(t)
})
}

226
http/skinsystem.go Normal file
View File

@@ -0,0 +1,226 @@
package http
import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
type SkinsRepository interface {
FindSkinByUsername(username string) (*model.Skin, error)
FindSkinByUserId(id int) (*model.Skin, error)
SaveSkin(skin *model.Skin) error
RemoveSkinByUserId(id int) error
RemoveSkinByUsername(username string) error
}
type CapesRepository interface {
FindCapeByUsername(username string) (*model.Cape, error)
}
type MojangTexturesProvider interface {
GetForUsername(username string) (*mojang.SignedTexturesResponse, error)
}
type Skinsystem struct {
Emitter
SkinsRepo SkinsRepository
CapesRepo CapesRepository
MojangTexturesProvider MojangTexturesProvider
TexturesExtraParamName string
TexturesExtraParamValue string
}
func (ctx *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks")
router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet)
// Legacy
router.HandleFunc("/skins", ctx.skinGetHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", ctx.capeGetHandler).Methods(http.MethodGet)
return router
}
func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != nil && rec.SkinId != 0 {
http.Redirect(response, request, rec.Url, 301)
return
}
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || 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 (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.skinHandler(response, request)
}
func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
rec, err := ctx.CapesRepo.FindCapeByUsername(username)
if err == nil && rec != nil {
request.Header.Set("Content-Type", "image/png")
_, _ = io.Copy(response, rec.File)
return
}
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNotFound)
return
}
texturesProp := mojangTextures.DecodeTextures()
cape := texturesProp.Textures.Cape
if cape == nil {
response.WriteHeader(http.StatusNotFound)
return
}
http.Redirect(response, request, cape.Url, 301)
}
func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) {
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
ctx.capeHandler(response, request)
}
func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var textures *mojang.TexturesResponse
skin, skinErr := ctx.SkinsRepo.FindSkinByUsername(username)
cape, capeErr := ctx.CapesRepo.FindCapeByUsername(username)
if (skinErr == nil && skin != nil && skin.SkinId != 0) || (capeErr == nil && cape != nil) {
textures = &mojang.TexturesResponse{}
if skinErr == nil && skin != nil && skin.SkinId != 0 {
skinTextures := &mojang.SkinTexturesResponse{
Url: skin.Url,
}
if skin.IsSlim {
skinTextures.Metadata = &mojang.SkinTexturesMetadata{
Model: "slim",
}
}
textures.Skin = skinTextures
}
if capeErr == nil && cape != nil {
textures.Cape = &mojang.CapeTexturesResponse{
// Use statically http since the application doesn't support TLS
Url: "http://" + request.Host + "/cloaks/" + username,
}
}
} else {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err != nil || mojangTextures == nil {
response.WriteHeader(http.StatusNoContent)
return
}
texturesProp := mojangTextures.DecodeTextures()
if texturesProp == nil {
ctx.Emit("skinsystem:error", errors.New("unable to find textures property"))
apiServerError(response)
return
}
textures = texturesProp.Textures
if textures.Skin == nil && textures.Cape == nil {
response.WriteHeader(http.StatusNoContent)
return
}
}
responseData, _ := json.Marshal(textures)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseData)
}
func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) {
username := parseUsername(mux.Vars(request)["username"])
var responseData *mojang.SignedTexturesResponse
rec, err := ctx.SkinsRepo.FindSkinByUsername(username)
if err == nil && rec != nil && rec.SkinId != 0 && rec.MojangTextures != "" {
responseData = &mojang.SignedTexturesResponse{
Id: strings.Replace(rec.Uuid, "-", "", -1),
Name: rec.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: rec.MojangSignature,
Value: rec.MojangTextures,
},
},
}
} else if request.URL.Query().Get("proxy") != "" {
mojangTextures, err := ctx.MojangTexturesProvider.GetForUsername(username)
if err == nil && mojangTextures != nil {
responseData = mojangTextures
}
}
if responseData == nil {
response.WriteHeader(http.StatusNoContent)
return
}
responseData.Props = append(responseData.Props, &mojang.Property{
Name: ctx.TexturesExtraParamName,
Value: ctx.TexturesExtraParamValue,
})
responseJson, _ := json.Marshal(responseData)
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write(responseJson)
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}

702
http/skinsystem_test.go Normal file
View File

@@ -0,0 +1,702 @@
package http
import (
"bytes"
"image"
"image/png"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/model"
)
/***************
* Setup mocks *
***************/
type skinsRepositoryMock struct {
mock.Mock
}
func (m *skinsRepositoryMock) FindSkinByUsername(username string) (*model.Skin, error) {
args := m.Called(username)
var result *model.Skin
if casted, ok := args.Get(0).(*model.Skin); ok {
result = casted
}
return result, args.Error(1)
}
func (m *skinsRepositoryMock) FindSkinByUserId(id int) (*model.Skin, error) {
args := m.Called(id)
var result *model.Skin
if casted, ok := args.Get(0).(*model.Skin); ok {
result = casted
}
return result, args.Error(1)
}
func (m *skinsRepositoryMock) SaveSkin(skin *model.Skin) error {
args := m.Called(skin)
return args.Error(0)
}
func (m *skinsRepositoryMock) RemoveSkinByUserId(id int) error {
args := m.Called(id)
return args.Error(0)
}
func (m *skinsRepositoryMock) RemoveSkinByUsername(username string) error {
args := m.Called(username)
return args.Error(0)
}
type capesRepositoryMock struct {
mock.Mock
}
func (m *capesRepositoryMock) FindCapeByUsername(username string) (*model.Cape, error) {
args := m.Called(username)
var result *model.Cape
if casted, ok := args.Get(0).(*model.Cape); ok {
result = casted
}
return result, args.Error(1)
}
type mojangTexturesProviderMock struct {
mock.Mock
}
func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
args := m.Called(username)
var result *mojang.SignedTexturesResponse
if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok {
result = casted
}
return result, args.Error(1)
}
type skinsystemTestSuite struct {
suite.Suite
App *Skinsystem
SkinsRepository *skinsRepositoryMock
CapesRepository *capesRepositoryMock
MojangTexturesProvider *mojangTexturesProviderMock
Emitter *emitterMock
}
/********************
* Setup test suite *
********************/
func (suite *skinsystemTestSuite) SetupTest() {
suite.SkinsRepository = &skinsRepositoryMock{}
suite.CapesRepository = &capesRepositoryMock{}
suite.MojangTexturesProvider = &mojangTexturesProviderMock{}
suite.Emitter = &emitterMock{}
suite.App = &Skinsystem{
SkinsRepo: suite.SkinsRepository,
CapesRepo: suite.CapesRepository,
MojangTexturesProvider: suite.MojangTexturesProvider,
Emitter: suite.Emitter,
TexturesExtraParamName: "texturesParamName",
TexturesExtraParamValue: "texturesParamValue",
}
}
func (suite *skinsystemTestSuite) TearDownTest() {
suite.SkinsRepository.AssertExpectations(suite.T())
suite.CapesRepository.AssertExpectations(suite.T())
suite.MojangTexturesProvider.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func (suite *skinsystemTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestSkinsystem(t *testing.T) {
suite.Run(t, new(skinsystemTestSuite))
}
type skinsystemTestCase struct {
Name string
BeforeTest func(suite *skinsystemTestSuite)
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
/************************
* Get skin tests cases *
************************/
var skinsTestsCases = []*skinsystemTestCase{
{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
suite.Equal("http://chrly/skin.png", response.Header.Get("Location"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
suite.Equal("http://mojang/skin.png", response.Header.Get("Location"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
}
func (suite *skinsystemTestSuite) TestSkin() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(301, resp.StatusCode)
suite.Equal("http://chrly/skin.png", resp.Header.Get("Location"))
})
}
func (suite *skinsystemTestSuite) TestSkinGET() {
for _, testCase := range skinsTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(400, resp.StatusCode)
})
}
/************************
* Get cape tests cases *
************************/
var capesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists in the local storage",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
responseData, _ := ioutil.ReadAll(response.Body)
suite.Equal(createCape(), responseData)
suite.Equal("image/png", response.Header.Get("Content-Type"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(301, response.StatusCode)
suite.Equal("http://mojang/cape.png", response.Header.Get("Location"))
},
},
{
Name: "Username doesn't exists on the local storage, but exists on Mojang and has no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
{
Name: "Username doesn't exists on the local storage and doesn't exists on Mojang",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(404, response.StatusCode)
},
},
}
func (suite *skinsystemTestSuite) TestCape() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Pass username with png extension", func() {
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
suite.Equal(createCape(), responseData)
suite.Equal("image/png", resp.Header.Get("Content-Type"))
})
}
func (suite *skinsystemTestSuite) TestCapeGET() {
for _, testCase := range capesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
suite.RunSubTest("Do not pass name param", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
resp := w.Result()
suite.Equal(400, resp.StatusCode)
})
}
/****************************
* Get textures tests cases *
****************************/
var texturesTestsCases = []*skinsystemTestCase{
{
Name: "Username exists and has skin, no cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
}
}`, string(body))
},
},
{
Name: "Username exists and has slim skin, no cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png",
"metadata": {
"model": "slim"
}
}
}`, string(body))
},
},
{
Name: "Username exists and has cape, no skin",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
{
Name: "Username exists and has both skin and cape",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://chrly/skin.png"
},
"CAPE": {
"url": "http://chrly/cloaks/mock_username"
}
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(true, true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"SKIN": {
"url": "http://mojang/skin.png"
},
"CAPE": {
"url": "http://mojang/cape.png"
}
}`, string(body))
},
},
{
Name: "Username not exists, but Mojang profile available, but there is no textures",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponse(false, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
},
},
{
Name: "Username not exists and Mojang profile unavailable",
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
}
func (suite *skinsystemTestSuite) TestTextures() {
for _, testCase := range texturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}
/***********************************
* Get signed textures tests cases *
***********************************/
type signedTexturesTestCase struct {
Name string
AllowProxy bool
BeforeTest func(suite *skinsystemTestSuite)
AfterTest func(suite *skinsystemTestSuite, response *http.Response)
}
var signedTexturesTestsCases = []*signedTexturesTestCase{
{
Name: "Username exists",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_username",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username exists, but has no signed textures",
AllowProxy: false,
BeforeTest: func(suite *skinsystemTestSuite) {
skinModel := createSkinModel("mock_username", true)
skinModel.MojangTextures = ""
skinModel.MojangSignature = ""
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
{
Name: "Username not exists, but Mojang profile is available and proxying is enabled",
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponse(true, false), nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "00000000000000000000000000000000",
"name": "mock_username",
"properties": [
{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ=="
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
},
},
{
Name: "Username not exists, Mojang profile is unavailable too and proxying is enabled",
AllowProxy: true,
BeforeTest: func(suite *skinsystemTestSuite) {
suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil)
suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *skinsystemTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Equal("", string(body))
},
},
}
func (suite *skinsystemTestSuite) TestSignedTextures() {
for _, testCase := range signedTexturesTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
var target string
if testCase.AllowProxy {
target = "http://chrly/textures/signed/mock_username?proxy=true"
} else {
target = "http://chrly/textures/signed/mock_username"
}
req := httptest.NewRequest("GET", target, nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}
/****************
* Custom tests *
****************/
func TestParseUsername(t *testing.T) {
assert := testify.New(t)
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
}
/*************
* Utilities *
*************/
func createSkinModel(username string, isSlim bool) *model.Skin {
return &model.Skin{
UserId: 1,
Username: username,
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", // Use non nil UUID to pass validation in api tests
SkinId: 1,
Url: "http://chrly/skin.png",
MojangTextures: "mocked textures base64",
MojangSignature: "mocked signature",
IsSlim: isSlim,
}
}
func createCape() []byte {
img := image.NewAlpha(image.Rect(0, 0, 64, 32))
writer := &bytes.Buffer{}
_ = png.Encode(writer, img)
pngBytes, _ := ioutil.ReadAll(writer)
return pngBytes
}
func createCapeModel() *model.Cape {
return &model.Cape{File: bytes.NewReader(createCape())}
}
func createMojangResponse(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_username",
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_username",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
},
},
}
return response
}

53
http/uuids_worker.go Normal file
View File

@@ -0,0 +1,53 @@
package http
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/elyby/chrly/api/mojang"
)
type MojangUuidsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type UUIDsWorker struct {
MojangUuidsProvider
}
func (ctx *UUIDsWorker) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.Handle("/mojang-uuid/{username}", http.HandlerFunc(ctx.getUUIDHandler)).Methods("GET")
return router
}
func (ctx *UUIDsWorker) getUUIDHandler(response http.ResponseWriter, request *http.Request) {
username := mux.Vars(request)["username"]
profile, err := ctx.GetUuid(username)
if err != nil {
if _, ok := err.(*mojang.TooManyRequestsError); ok {
response.WriteHeader(http.StatusTooManyRequests)
return
}
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusInternalServerError)
result, _ := json.Marshal(map[string]interface{}{
"provider": err.Error(),
})
_, _ = response.Write(result)
return
}
if profile == nil {
response.WriteHeader(http.StatusNoContent)
return
}
response.Header().Set("Content-Type", "application/json")
responseData, _ := json.Marshal(profile)
_, _ = response.Write(responseData)
}

154
http/uuids_worker_test.go Normal file
View File

@@ -0,0 +1,154 @@
package http
import (
"errors"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
/***************
* Setup mocks *
***************/
type uuidsProviderMock struct {
mock.Mock
}
func (m *uuidsProviderMock) GetUuid(username string) (*mojang.ProfileInfo, error) {
args := m.Called(username)
var result *mojang.ProfileInfo
if casted, ok := args.Get(0).(*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type uuidsWorkerTestSuite struct {
suite.Suite
App *UUIDsWorker
UuidsProvider *uuidsProviderMock
}
/********************
* Setup test suite *
********************/
func (suite *uuidsWorkerTestSuite) SetupTest() {
suite.UuidsProvider = &uuidsProviderMock{}
suite.App = &UUIDsWorker{
MojangUuidsProvider: suite.UuidsProvider,
}
}
func (suite *uuidsWorkerTestSuite) TearDownTest() {
suite.UuidsProvider.AssertExpectations(suite.T())
}
func (suite *uuidsWorkerTestSuite) RunSubTest(name string, subTest func()) {
suite.SetupTest()
suite.Run(name, subTest)
suite.TearDownTest()
}
/*************
* Run tests *
*************/
func TestUUIDsWorker(t *testing.T) {
suite.Run(t, new(uuidsWorkerTestSuite))
}
type uuidsWorkerTestCase struct {
Name string
BeforeTest func(suite *uuidsWorkerTestSuite)
AfterTest func(suite *uuidsWorkerTestSuite, response *http.Response)
}
/************************
* Get UUID tests cases *
************************/
var getUuidTestsCases = []*uuidsWorkerTestCase{
{
Name: "Success provider response",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.UuidsProvider.On("GetUuid", "mock_username").Return(&mojang.ProfileInfo{
Id: "0fcc38620f1845f3a54e1b523c1bd1c7",
Name: "mock_username",
}, nil)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(200, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"id": "0fcc38620f1845f3a54e1b523c1bd1c7",
"name": "mock_username"
}`, string(body))
},
},
{
Name: "Receive empty response from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, nil)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(204, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Assert().Empty(body)
},
},
{
Name: "Receive error from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
err := errors.New("this is an error")
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(500, response.StatusCode)
suite.Equal("application/json", response.Header.Get("Content-Type"))
body, _ := ioutil.ReadAll(response.Body)
suite.JSONEq(`{
"provider": "this is an error"
}`, string(body))
},
},
{
Name: "Receive Too Many Requests from UUIDs provider",
BeforeTest: func(suite *uuidsWorkerTestSuite) {
err := &mojang.TooManyRequestsError{}
suite.UuidsProvider.On("GetUuid", "mock_username").Return(nil, err)
},
AfterTest: func(suite *uuidsWorkerTestSuite, response *http.Response) {
suite.Equal(429, response.StatusCode)
body, _ := ioutil.ReadAll(response.Body)
suite.Empty(body)
},
},
}
func (suite *uuidsWorkerTestSuite) TestGetUUID() {
for _, testCase := range getUuidTestsCases {
suite.RunSubTest(testCase.Name, func() {
testCase.BeforeTest(suite)
req := httptest.NewRequest("GET", "http://chrly/mojang-uuid/mock_username", nil)
w := httptest.NewRecorder()
suite.App.Handler().ServeHTTP(w, req)
testCase.AfterTest(suite, w.Result())
})
}
}

12
main.go Normal file
View File

@@ -0,0 +1,12 @@
package main
import (
"runtime"
"github.com/elyby/chrly/cmd"
)
func main() {
runtime.GOMAXPROCS(runtime.NumCPU())
cmd.Execute()
}

9
model/cape.go Normal file
View File

@@ -0,0 +1,9 @@
package model
import (
"io"
)
type Cape struct {
File io.Reader
}

14
model/skin.go Normal file
View File

@@ -0,0 +1,14 @@
package model
type Skin struct {
UserId int `json:"userId"`
Uuid string `json:"uuid"`
Username string `json:"username"`
SkinId int `json:"skinId"`
Url string `json:"url"`
Is1_8 bool `json:"is1_8"`
IsSlim bool `json:"isSlim"`
MojangTextures string `json:"mojangTextures"`
MojangSignature string `json:"mojangSignature"`
OldUsername string
}

View File

@@ -1,39 +0,0 @@
<?php
use Phalcon\Mvc\Collection;
/**
* @property string $id
*/
class Skins extends Collection {
public $_id;
public $userId;
public $nickname;
public $skinId;
public $url;
public $is1_8;
public $isSlim;
public $hash;
public function getId() {
return $this->_id;
}
public function getSource() {
return 'skins';
}
/**
* @param string $nickname
* @return bool|Skins
*/
public static function findByNickname($nickname) {
return static::findFirst([
[
'nickname' => mb_convert_case($nickname, MB_CASE_LOWER, ENCODING),
],
]);
}
}

View File

@@ -0,0 +1,139 @@
package mojangtextures
import (
"strings"
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
)
type jobResult struct {
profile *mojang.ProfileInfo
error error
}
type jobItem struct {
username string
respondChan chan *jobResult
}
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) Size() int {
s.lock.Lock()
defer s.lock.Unlock()
return s.size()
}
func (s *jobsQueue) size() int {
return len(s.items)
}
var usernamesToUuids = mojang.UsernamesToUuids
var forever = func() bool {
return true
}
type BatchUuidsProvider struct {
Emitter
IterationDelay time.Duration
IterationSize int
onFirstCall sync.Once
queue jobsQueue
}
func (ctx *BatchUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
ctx.onFirstCall.Do(func() {
ctx.queue.New()
ctx.startQueue()
})
resultChan := make(chan *jobResult)
ctx.queue.Enqueue(&jobItem{username, resultChan})
ctx.Emit("mojang_textures:batch_uuids_provider:queued", username)
result := <-resultChan
return result.profile, result.error
}
func (ctx *BatchUuidsProvider) startQueue() {
go func() {
time.Sleep(ctx.IterationDelay)
for forever() {
ctx.Emit("mojang_textures:batch_uuids_provider:before_round")
ctx.queueRound()
ctx.Emit("mojang_textures:batch_uuids_provider:after_round")
time.Sleep(ctx.IterationDelay)
}
}()
}
func (ctx *BatchUuidsProvider) queueRound() {
queueSize := ctx.queue.Size()
jobs := ctx.queue.Dequeue(ctx.IterationSize)
var usernames []string
for _, job := range jobs {
usernames = append(usernames, job.username)
}
ctx.Emit("mojang_textures:batch_uuids_provider:round", usernames, queueSize-len(jobs))
if len(usernames) == 0 {
return
}
profiles, err := usernamesToUuids(usernames)
ctx.Emit("mojang_textures:batch_uuids_provider:result", usernames, profiles, err)
for _, job := range jobs {
go func(job *jobItem) {
response := &jobResult{}
if err != nil {
response.error = err
} else {
// The profiles in the response aren't ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.username, profile.Name) {
response.profile = profile
break
}
}
}
job.respondChan <- response
}(job)
}
}

View File

@@ -0,0 +1,300 @@
package mojangtextures
import (
"crypto/rand"
"encoding/base64"
"strings"
"testing"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestJobsQueue(t *testing.T) {
createQueue := func() *jobsQueue {
queue := &jobsQueue{}
queue.New()
return queue
}
t.Run("Enqueue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
assert.Equal(3, s.Size())
})
t.Run("Dequeue", func(t *testing.T) {
assert := testify.New(t)
s := createQueue()
s.Enqueue(&jobItem{username: "username1"})
s.Enqueue(&jobItem{username: "username2"})
s.Enqueue(&jobItem{username: "username3"})
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)
})
}
// This is really stupid test just to get 100% coverage on this package :)
func TestBatchUuidsProvider_forever(t *testing.T) {
testify.True(t, forever())
}
type mojangUsernamesToUuidsRequestMock struct {
mock.Mock
}
func (o *mojangUsernamesToUuidsRequestMock) 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)
}
type batchUuidsProviderGetUuidResult struct {
Result *mojang.ProfileInfo
Error error
}
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
GetUuidAsync func(username string) chan *batchUuidsProviderGetUuidResult
Emitter *mockEmitter
MojangApi *mojangUsernamesToUuidsRequestMock
Iterate func()
done func()
iterateChan chan bool
}
func (suite *batchUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Provider = &BatchUuidsProvider{
Emitter: suite.Emitter,
IterationDelay: 0,
IterationSize: 10,
}
suite.iterateChan = make(chan bool)
forever = func() bool {
return <-suite.iterateChan
}
suite.Iterate = func() {
suite.iterateChan <- true
}
suite.done = func() {
suite.iterateChan <- false
}
suite.GetUuidAsync = func(username string) chan *batchUuidsProviderGetUuidResult {
s := make(chan bool)
// This dirty hack ensures, that the username will be queued before we return control to the caller.
// It's needed to keep expected calls order and prevent cases when iteration happens before all usernames
// will be queued.
suite.Emitter.On("Emit",
"mojang_textures:batch_uuids_provider:queued",
username,
).Once().Run(func(args mock.Arguments) {
s <- true
})
c := make(chan *batchUuidsProviderGetUuidResult)
go func() {
profile, err := suite.Provider.GetUuid(username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
}()
<-s
return c
}
suite.MojangApi = &mojangUsernamesToUuidsRequestMock{}
usernamesToUuids = suite.MojangApi.UsernamesToUuids
}
func (suite *batchUuidsProviderTestSuite) TearDownTest() {
suite.done()
suite.Emitter.AssertExpectations(suite.T())
suite.MojangApi.AssertExpectations(suite.T())
}
func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForOneUsername() {
expectedUsernames := []string{"username"}
expectedResult := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResponse := []*mojang.ProfileInfo{expectedResult}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{expectedResult}, nil)
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Equal(expectedResult, result.Result)
suite.Assert().Nil(result.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernames() {
expectedUsernames := []string{"username1", "username2"}
expectedResult1 := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &mojang.ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
expectedResponse := []*mojang.ProfileInfo{expectedResult1, expectedResult2}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return([]*mojang.ProfileInfo{
expectedResult1,
expectedResult2,
}, nil)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
result1 := <-resultChan1
suite.Assert().Equal(expectedResult1, result1.Result)
suite.Assert().Nil(result1.Error)
result2 := <-resultChan2
suite.Assert().Equal(expectedResult2, result2.Result)
suite.Assert().Nil(result2.Error)
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForMoreThan10Usernames() {
usernames := make([]string, 12)
for i := 0; i < cap(usernames); i++ {
usernames[i] = randStr(8)
}
// In this test we're not testing response, so always return an empty resultset
expectedResponse := []*mojang.ProfileInfo{}
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[0:10], 2).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[0:10], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", usernames[10:12], 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", usernames[10:12], expectedResponse, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Twice()
suite.MojangApi.On("UsernamesToUuids", usernames[0:10]).Once().Return(expectedResponse, nil)
suite.MojangApi.On("UsernamesToUuids", usernames[10:12]).Once().Return(expectedResponse, nil)
channels := make([]chan *batchUuidsProviderGetUuidResult, len(usernames))
for i, username := range usernames {
channels[i] = suite.GetUuidAsync(username)
}
suite.Iterate()
suite.Iterate()
for _, channel := range channels {
<-channel
}
}
func (suite *batchUuidsProviderTestSuite) TestDoNothingWhenNoTasks() {
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Times(3)
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", []string{"username"}, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", []string{"username"}, mock.Anything, nil).Once()
var nilStringSlice []string
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", nilStringSlice, 0).Twice()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Times(3)
suite.MojangApi.On("UsernamesToUuids", []string{"username"}).Once().Return([]*mojang.ProfileInfo{}, nil)
// Perform first iteration and await it finishes
resultChan := suite.GetUuidAsync("username")
suite.Iterate()
result := <-resultChan
suite.Assert().Nil(result.Result)
suite.Assert().Nil(result.Error)
// Let it to perform a few more iterations to ensure, that there are no calls to external APIs
suite.Iterate()
suite.Iterate()
}
func (suite *batchUuidsProviderTestSuite) TestGetUuidForTwoUsernamesWithAnError() {
expectedUsernames := []string{"username1", "username2"}
expectedError := &mojang.TooManyRequestsError{}
var nilProfilesResponse []*mojang.ProfileInfo
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:before_round").Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:round", expectedUsernames, 0).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:result", expectedUsernames, nilProfilesResponse, expectedError).Once()
suite.Emitter.On("Emit", "mojang_textures:batch_uuids_provider:after_round").Once()
suite.MojangApi.On("UsernamesToUuids", expectedUsernames).Once().Return(nil, expectedError)
resultChan1 := suite.GetUuidAsync("username1")
resultChan2 := suite.GetUuidAsync("username2")
suite.Iterate()
result1 := <-resultChan1
suite.Assert().Nil(result1.Result)
suite.Assert().Equal(expectedError, result1.Error)
result2 := <-resultChan2
suite.Assert().Nil(result2.Result)
suite.Assert().Equal(expectedError, result2.Error)
}
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,115 @@
package mojangtextures
import (
"sync"
"time"
"github.com/elyby/chrly/api/mojang"
"github.com/tevino/abool"
)
var now = time.Now
type inMemoryItem struct {
textures *mojang.SignedTexturesResponse
timestamp int64
}
type InMemoryTexturesStorage struct {
GCPeriod time.Duration
Duration time.Duration
lock sync.RWMutex
data map[string]*inMemoryItem
working *abool.AtomicBool
}
func NewInMemoryTexturesStorage() *InMemoryTexturesStorage {
storage := &InMemoryTexturesStorage{
GCPeriod: 10 * time.Second,
Duration: time.Minute + 10*time.Second,
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(s.GCPeriod)
// TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right
for s.working.IsSet() {
start := time.Now()
s.gc()
time.Sleep(s.GCPeriod - time.Since(start))
}
}()
}
s.working.Set()
}
func (s *InMemoryTexturesStorage) Stop() {
s.working.UnSet()
}
func (s *InMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
s.lock.RLock()
defer s.lock.RUnlock()
item, exists := s.data[uuid]
validRange := s.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 := s.getMinimalNotExpiredTimestamp()
for uuid, value := range s.data {
if maxTime > value.timestamp {
delete(s.data, uuid)
}
}
}
func (s *InMemoryTexturesStorage) getMinimalNotExpiredTimestamp() int64 {
return unixNanoToUnixMicro(now().Add(s.Duration * time.Duration(-1)).UnixNano())
}
func unixNanoToUnixMicro(unixNano int64) int64 {
return unixNano / 10e5
}

View File

@@ -0,0 +1,200 @@
package mojangtextures
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 := NewInMemoryTexturesStorage()
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 := NewInMemoryTexturesStorage()
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 := NewInMemoryTexturesStorage()
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 := NewInMemoryTexturesStorage()
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 := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithoutSkin)
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", texturesWithSkin)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.NotEqual(texturesWithoutSkin, result)
assert.Equal(texturesWithSkin, result)
assert.Nil(err)
})
t.Run("store nil textures", func(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", nil)
result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
assert.Nil(result)
assert.Nil(err)
})
t.Run("should panic if textures prop is not decoded", func(t *testing.T) {
assert := testify.New(t)
toStore := &mojang.SignedTexturesResponse{
Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
Name: "mock",
Props: []*mojang.Property{},
}
assert.PanicsWithValue("unable to decode textures", func() {
storage := NewInMemoryTexturesStorage()
storage.StoreTextures("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", toStore)
})
})
}
func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) {
assert := testify.New(t)
storage := NewInMemoryTexturesStorage()
storage.GCPeriod = 10 * time.Millisecond
storage.Duration = 10 * time.Millisecond
textures1 := &mojang.SignedTexturesResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock1",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock1",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
textures2 := &mojang.SignedTexturesResponse{
Id: "b5d58475007d4f9e9ddd1403e2497579",
Name: "mock2",
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(&mojang.TexturesProp{
Timestamp: time.Now().Add(storage.GCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5,
ProfileID: "b5d58475007d4f9e9ddd1403e2497579",
ProfileName: "mock2",
Textures: &mojang.TexturesResponse{},
}),
},
},
}
storage.StoreTextures("dead24f9a4fa4877b7b04c8c6c72bb46", textures1)
storage.StoreTextures("b5d58475007d4f9e9ddd1403e2497579", textures2)
storage.Start()
time.Sleep(storage.GCPeriod + time.Millisecond) // Let it start first iteration
_, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Nil(textures1Err)
assert.Error(textures2Err)
time.Sleep(storage.GCPeriod + time.Millisecond) // Let another iteration happen
_, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46")
_, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579")
assert.Error(textures1Err)
assert.Error(textures2Err)
storage.Stop()
}

View File

@@ -0,0 +1,19 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
var uuidToTextures = mojang.UuidToTextures
type MojangApiTexturesProvider struct {
Emitter
}
func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:mojang_api_textures_provider:before_request", uuid)
result, err := uuidToTextures(uuid, true)
ctx.Emit("mojang_textures:mojang_api_textures_provider:after_request", result, err)
return result, err
}

View File

@@ -0,0 +1,96 @@
package mojangtextures
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
type mojangUuidToTexturesRequestMock struct {
mock.Mock
}
func (o *mojangUuidToTexturesRequestMock) 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 mojangApiTexturesProviderTestSuite struct {
suite.Suite
Provider *MojangApiTexturesProvider
Emitter *mockEmitter
MojangApi *mojangUuidToTexturesRequestMock
}
func (suite *mojangApiTexturesProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.MojangApi = &mojangUuidToTexturesRequestMock{}
suite.Provider = &MojangApiTexturesProvider{
Emitter: suite.Emitter,
}
uuidToTextures = suite.MojangApi.UuidToTextures
}
func (suite *mojangApiTexturesProviderTestSuite) TearDownTest() {
suite.MojangApi.AssertExpectations(suite.T())
suite.Emitter.AssertExpectations(suite.T())
}
func TestMojangApiTexturesProvider(t *testing.T) {
suite.Run(t, new(mojangApiTexturesProviderTestSuite))
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTextures() {
expectedResult := &mojang.SignedTexturesResponse{
Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Name: "username",
}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(expectedResult, nil)
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:before_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
expectedResult,
nil,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Equal(expectedResult, result)
suite.Assert().Nil(err)
}
func (suite *mojangApiTexturesProviderTestSuite) TestGetTexturesWithError() {
var expectedResponse *mojang.SignedTexturesResponse
expectedError := &mojang.TooManyRequestsError{}
suite.MojangApi.On("UuidToTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:before_request",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
).Once()
suite.Emitter.On("Emit",
"mojang_textures:mojang_api_textures_provider:after_request",
expectedResponse,
expectedError,
).Once()
result, err := suite.Provider.GetTextures("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
suite.Assert().Nil(result)
suite.Assert().Equal(expectedError, err)
}

View File

@@ -0,0 +1,195 @@
package mojangtextures
import (
"errors"
"regexp"
"strings"
"sync"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/dispatcher"
)
type broadcastResult struct {
textures *mojang.SignedTexturesResponse
error error
}
type broadcaster struct {
lock sync.Mutex
listeners map[string][]chan *broadcastResult
}
func createBroadcaster() *broadcaster {
return &broadcaster{
listeners: make(map[string][]chan *broadcastResult),
}
}
// Returns a boolean value, which will be true if the passed username didn't exist before
func (c *broadcaster) AddListener(username string, resultChan chan *broadcastResult) 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 *broadcastResult{resultChan}
return true
}
func (c *broadcaster) BroadcastAndRemove(username string, result *broadcastResult) {
c.lock.Lock()
defer c.lock.Unlock()
val, ok := c.listeners[username]
if !ok {
return
}
for _, channel := range val {
go func(channel chan *broadcastResult) {
channel <- result
close(channel)
}(channel)
}
delete(c.listeners, username)
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
type UUIDsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type TexturesProvider interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
}
type Emitter interface {
dispatcher.Emitter
}
type Provider struct {
Emitter
UUIDsProvider
TexturesProvider
Storage
onFirstCall sync.Once
*broadcaster
}
func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
ctx.onFirstCall.Do(func() {
ctx.broadcaster = createBroadcaster()
})
if !allowedUsernamesRegex.MatchString(username) {
return nil, errors.New("invalid username")
}
username = strings.ToLower(username)
ctx.Emit("mojang_textures:call", username)
uuid, err := ctx.getUuidFromCache(username)
if err == nil && uuid == "" {
return nil, nil
}
if uuid != "" {
textures, err := ctx.getTexturesFromCache(uuid)
if err == nil {
return textures, nil
}
}
resultChan := make(chan *broadcastResult)
isFirstListener := ctx.broadcaster.AddListener(username, resultChan)
if isFirstListener {
go ctx.getResultAndBroadcast(username, uuid)
} else {
ctx.Emit("mojang_textures:already_processing", username)
}
result := <-resultChan
return result.textures, result.error
}
func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
ctx.Emit("mojang_textures:before_result", username, uuid)
result := ctx.getResult(username, uuid)
ctx.Emit("mojang_textures:after_result", username, result.textures, result.error)
ctx.broadcaster.BroadcastAndRemove(username, result)
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
if uuid == "" {
profile, err := ctx.getUuid(username)
if err != nil {
return &broadcastResult{nil, err}
}
uuid = ""
if profile != nil {
uuid = profile.Id
}
_ = ctx.Storage.StoreUuid(username, uuid)
if uuid == "" {
return &broadcastResult{nil, nil}
}
}
textures, err := ctx.getTextures(uuid)
if err != nil {
return &broadcastResult{nil, err}
}
// Mojang can respond with an error, but it will still count as a hit,
// therefore store the result even if textures is nil to prevent 429 error
ctx.Storage.StoreTextures(uuid, textures)
return &broadcastResult{textures, nil}
}
func (ctx *Provider) getUuidFromCache(username string) (string, error) {
ctx.Emit("mojang_textures:usernames:before_cache", username)
uuid, err := ctx.Storage.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_cache", username, uuid, err)
return uuid, err
}
func (ctx *Provider) getTexturesFromCache(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:textures:before_cache", uuid)
textures, err := ctx.Storage.GetTextures(uuid)
ctx.Emit("mojang_textures:textures:after_cache", uuid, textures, err)
return textures, err
}
func (ctx *Provider) getUuid(username string) (*mojang.ProfileInfo, error) {
ctx.Emit("mojang_textures:usernames:before_call", username)
profile, err := ctx.UUIDsProvider.GetUuid(username)
ctx.Emit("mojang_textures:usernames:after_call", username, profile, err)
return profile, err
}
func (ctx *Provider) getTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
ctx.Emit("mojang_textures:textures:before_call", uuid)
textures, err := ctx.TexturesProvider.GetTextures(uuid)
ctx.Emit("mojang_textures:textures:after_call", uuid, textures, err)
return textures, err
}

View File

@@ -0,0 +1,406 @@
package mojangtextures
import (
"errors"
"sync"
"testing"
"time"
testify "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"github.com/elyby/chrly/api/mojang"
)
func TestBroadcaster(t *testing.T) {
t.Run("GetOrAppend", func(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 := createBroadcaster()
channel := make(chan *broadcastResult)
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 := createBroadcaster()
channel1 := make(chan *broadcastResult)
isFirstListener := broadcaster.AddListener("mock", channel1)
assert.True(isFirstListener)
channel2 := make(chan *broadcastResult)
isFirstListener = broadcaster.AddListener("mock", channel2)
assert.False(isFirstListener)
channel3 := make(chan *broadcastResult)
isFirstListener = broadcaster.AddListener("mock", channel3)
assert.False(isFirstListener)
})
})
t.Run("BroadcastAndRemove", func(t *testing.T) {
t.Run("should broadcast to all listeners and remove the key", func(t *testing.T) {
assert := testify.New(t)
broadcaster := createBroadcaster()
channel1 := make(chan *broadcastResult)
channel2 := make(chan *broadcastResult)
broadcaster.AddListener("mock", channel1)
broadcaster.AddListener("mock", channel2)
result := &broadcastResult{}
broadcaster.BroadcastAndRemove("mock", result)
assert.Equal(result, <-channel1)
assert.Equal(result, <-channel2)
channel3 := make(chan *broadcastResult)
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 := createBroadcaster()
broadcaster.BroadcastAndRemove("mock", &broadcastResult{})
})
})
})
}
type mockEmitter struct {
mock.Mock
}
func (e *mockEmitter) Emit(name string, args ...interface{}) {
e.Called(append([]interface{}{name}, args...)...)
}
type mockUuidsProvider struct {
mock.Mock
}
func (m *mockUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
args := m.Called(username)
var result *mojang.ProfileInfo
if casted, ok := args.Get(0).(*mojang.ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type mockTexturesProvider struct {
mock.Mock
}
func (m *mockTexturesProvider) 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)
}
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 providerTestSuite struct {
suite.Suite
Provider *Provider
Emitter *mockEmitter
UuidsProvider *mockUuidsProvider
TexturesProvider *mockTexturesProvider
Storage *mockStorage
}
func (suite *providerTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.UuidsProvider = &mockUuidsProvider{}
suite.TexturesProvider = &mockTexturesProvider{}
suite.Storage = &mockStorage{}
suite.Provider = &Provider{
Emitter: suite.Emitter,
UUIDsProvider: suite.UuidsProvider,
TexturesProvider: suite.TexturesProvider,
Storage: suite.Storage,
}
}
func (suite *providerTestSuite) TearDownTest() {
suite.Emitter.AssertExpectations(suite.T())
suite.UuidsProvider.AssertExpectations(suite.T())
suite.TexturesProvider.AssertExpectations(suite.T())
suite.Storage.AssertExpectations(suite.T())
}
func TestProvider(t *testing.T) {
suite.Run(t, new(providerTestSuite))
}
func (suite *providerTestSuite) TestGetForUsernameWithoutAnyCache() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUuid() {
var expectedCachedTextures *mojang.SignedTexturesResponse
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedCachedTextures, &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, &ValueNotFound{})
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithFullyCachedResult() {
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_cache", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
suite.Storage.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Equal(expectedResult, result)
}
func (suite *providerTestSuite) TestGetForUsernameWithCachedUnknownUuid() {
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Nil(err)
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "").Once().Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Nil(err)
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
var expectedResult *mojang.SignedTexturesResponse
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := suite.Provider.GetForUsername("username")
suite.Assert().Equal(expectedResult, result)
suite.Assert().Nil(err)
}
func (suite *providerTestSuite) TestGetForTheSameUsernames() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &mojang.SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
suite.Emitter.On("Emit", "mojang_textures:call", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Twice()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Twice()
suite.Emitter.On("Emit", "mojang_textures:already_processing", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, nil).Once()
suite.Storage.On("GetUuid", "username").Twice().Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
suite.Storage.On("StoreTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult).Once()
// If possible, than remove this .After call
suite.UuidsProvider.On("GetUuid", "username").Once().After(time.Millisecond).Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
results := make([]*mojang.SignedTexturesResponse, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
textures, _ := suite.Provider.GetForUsername("username")
results[i] = textures
wg.Done()
}(i)
}
wg.Wait()
suite.Assert().Equal(expectedResult, results[0])
suite.Assert().Equal(expectedResult, results[1])
}
func (suite *providerTestSuite) TestGetForNotAllowedMojangUsername() {
result, err := suite.Provider.GetForUsername("Not allowed")
suite.Assert().Error(err, "invalid username")
suite.Assert().Nil(result)
}
func (suite *providerTestSuite) TestGetErrorFromUuidsProvider() {
var expectedProfile *mojang.ProfileInfo
var expectedResult *mojang.SignedTexturesResponse
err := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Once().Return("", &ValueNotFound{})
suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, err)
result, resErr := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(err, resErr)
}
func (suite *providerTestSuite) TestGetErrorFromTexturesProvider() {
expectedProfile := &mojang.ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
var expectedResult *mojang.SignedTexturesResponse
err := errors.New("mock error")
suite.Emitter.On("Emit", "mojang_textures:call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_cache", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_cache", "username", "", &ValueNotFound{}).Once()
suite.Emitter.On("Emit", "mojang_textures:before_result", "username", "").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:before_call", "username").Once()
suite.Emitter.On("Emit", "mojang_textures:usernames:after_call", "username", expectedProfile, nil).Once()
suite.Emitter.On("Emit", "mojang_textures:textures:before_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once()
suite.Emitter.On("Emit", "mojang_textures:textures:after_call", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", expectedResult, err).Once()
suite.Emitter.On("Emit", "mojang_textures:after_result", "username", expectedResult, err).Once()
suite.Storage.On("GetUuid", "username").Return("", &ValueNotFound{})
suite.Storage.On("StoreUuid", "username", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil)
suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil)
suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
result, resErr := suite.Provider.GetForUsername("username")
suite.Assert().Nil(result)
suite.Assert().Equal(err, resErr)
}

View File

@@ -0,0 +1,12 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
type NilProvider struct {
}
func (p *NilProvider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
return nil, nil
}

View File

@@ -0,0 +1,14 @@
package mojangtextures
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNilProvider_GetForUsername(t *testing.T) {
provider := &NilProvider{}
result, err := provider.GetForUsername("username")
assert.Nil(t, result)
assert.Nil(t, err)
}

View File

@@ -0,0 +1,67 @@
package mojangtextures
import (
"encoding/json"
"io/ioutil"
"net/http"
. "net/url"
"path"
"github.com/elyby/chrly/api/mojang"
"github.com/elyby/chrly/version"
)
var HttpClient = &http.Client{
Transport: &http.Transport{
MaxIdleConnsPerHost: 1024,
},
}
type RemoteApiUuidsProvider struct {
Emitter
Url URL
}
func (ctx *RemoteApiUuidsProvider) GetUuid(username string) (*mojang.ProfileInfo, error) {
url := ctx.Url
url.Path = path.Join(url.Path, username)
urlStr := url.String()
request, _ := http.NewRequest("GET", urlStr, nil)
request.Header.Add("Accept", "application/json")
// Change default User-Agent to allow specify "Username -> UUID at time" Mojang's api endpoint
request.Header.Add("User-Agent", "Chrly/"+version.Version())
ctx.Emit("mojang_textures:remote_api_uuids_provider:before_request", urlStr)
response, err := HttpClient.Do(request)
ctx.Emit("mojang_textures:remote_api_uuids_provider:after_request", response, err)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode == 204 {
return nil, nil
}
if response.StatusCode != 200 {
return nil, &UnexpectedRemoteApiResponse{response}
}
var result *mojang.ProfileInfo
body, _ := ioutil.ReadAll(response.Body)
err = json.Unmarshal(body, &result)
if err != nil {
return nil, err
}
return result, nil
}
type UnexpectedRemoteApiResponse struct {
Response *http.Response
}
func (*UnexpectedRemoteApiResponse) Error() string {
return "Unexpected remote api response"
}

View File

@@ -0,0 +1,167 @@
package mojangtextures
import (
"net"
"net/http"
. "net/url"
"testing"
"github.com/h2non/gock"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
type remoteApiUuidsProviderTestSuite struct {
suite.Suite
Provider *RemoteApiUuidsProvider
Emitter *mockEmitter
}
func (suite *remoteApiUuidsProviderTestSuite) SetupSuite() {
client := &http.Client{}
gock.InterceptClient(client)
HttpClient = client
}
func (suite *remoteApiUuidsProviderTestSuite) SetupTest() {
suite.Emitter = &mockEmitter{}
suite.Provider = &RemoteApiUuidsProvider{
Emitter: suite.Emitter,
}
}
func (suite *remoteApiUuidsProviderTestSuite) TearDownTest() {
suite.Emitter.AssertExpectations(suite.T())
gock.Off()
}
func TestRemoteApiUuidsProvider(t *testing.T) {
suite.Run(t, new(remoteApiUuidsProviderTestSuite))
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForValidUsername() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(200).
JSON(map[string]interface{}{
"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"name": "username",
})
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
if assert.NoError(err) {
assert.Equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", result.Id)
assert.Equal("username", result.Name)
assert.False(result.IsLegacy)
assert.False(result.IsDemo)
}
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotExistsUsername() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(204)
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
assert.Nil(err)
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNon20xResponse() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(504).
BodyString("504 Gateway Timeout")
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
assert.EqualError(err, "Unexpected remote api response")
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForNotSuccessRequest() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
mock.AnythingOfType("*url.Error"),
).Once()
expectedError := &net.OpError{Op: "dial"}
gock.New("http://example.com").
Get("/subpath/username").
ReplyError(expectedError)
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
if assert.Error(err) {
assert.IsType(&Error{}, err)
casterErr, _ := err.(*Error)
assert.Equal(expectedError, casterErr.Err)
}
}
func (suite *remoteApiUuidsProviderTestSuite) TestGetUuidForInvalidSuccessResponse() {
suite.Emitter.On("Emit", "mojang_textures:remote_api_uuids_provider:before_request", "http://example.com/subpath/username").Once()
suite.Emitter.On("Emit",
"mojang_textures:remote_api_uuids_provider:after_request",
mock.AnythingOfType("*http.Response"),
nil,
).Once()
gock.New("http://example.com").
Get("/subpath/username").
Reply(200).
BodyString("completely not json")
suite.Provider.Url = shouldParseUrl("http://example.com/subpath")
result, err := suite.Provider.GetUuid("username")
assert := suite.Assert()
assert.Nil(result)
assert.Error(err)
}
func shouldParseUrl(rawUrl string) URL {
url, err := Parse(rawUrl)
if err != nil {
panic(err)
}
return *url
}

61
mojangtextures/storage.go Normal file
View File

@@ -0,0 +1,61 @@
package mojangtextures
import (
"github.com/elyby/chrly/api/mojang"
)
// UuidsStorage is a key-value storage of Mojang usernames pairs to its UUIDs,
// used to reduce the load on the account information queue
type UuidsStorage interface {
// Since only primitive types are used in this method, you should return a special error ValueNotFound
// to return the information that no error has occurred and username does not have uuid
GetUuid(username string) (string, error)
// An empty uuid value can be passed if the corresponding account has not been found
StoreUuid(username string, uuid string) error
}
// TexturesStorage is a Mojang's textures storage, used as a values cache to avoid 429 errors
type TexturesStorage interface {
// Error should not have nil value only if the repository failed to determine if there are any textures
// for this uuid or not at all. If there is information about the absence of textures, nil nil should be returned
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
// The nil value can be passed when there are no textures for the corresponding uuid and we know about it
StoreTextures(uuid string, textures *mojang.SignedTexturesResponse)
}
type Storage interface {
UuidsStorage
TexturesStorage
}
// SeparatedStorage allows you to use separate storage engines to satisfy
// the Storage interface
type SeparatedStorage struct {
UuidsStorage
TexturesStorage
}
func (s *SeparatedStorage) GetUuid(username string) (string, error) {
return s.UuidsStorage.GetUuid(username)
}
func (s *SeparatedStorage) StoreUuid(username string, uuid string) error {
return s.UuidsStorage.StoreUuid(username, uuid)
}
func (s *SeparatedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) {
return s.TexturesStorage.GetTextures(uuid)
}
func (s *SeparatedStorage) StoreTextures(uuid string, textures *mojang.SignedTexturesResponse) {
s.TexturesStorage.StoreTextures(uuid, textures)
}
// This error can be used to indicate, that requested
// value doesn't exists in the storage
type ValueNotFound struct {
}
func (*ValueNotFound) Error() string {
return "value not found in the storage"
}

View File

@@ -0,0 +1,89 @@
package mojangtextures
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() (*SeparatedStorage, *uuidsStorageMock, *texturesStorageMock) {
uuidsStorage := &uuidsStorageMock{}
texturesStorage := &texturesStorageMock{}
return &SeparatedStorage{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())
}

View File

@@ -1,40 +0,0 @@
location /minecraft.php {
if ($arg_name = "") {
return 400;
}
if ($arg_type = "cloack") {
rewrite .* http://skins.minecraft.net/MinecraftCloaks/$arg_name.png? permanent;
break;
}
if ($arg_type = "skin") {
rewrite .* /skins/$arg_name last;
}
return 404;
}
location /cloaks/ {
try_files $uri $uri.png @cloaks;
}
location @cloaks {
rewrite ^/cloaks/(.+?)(\.[^.]*$|$)$ http://skins.minecraft.net/MinecraftCloaks/$1.png? permanent;
}
location ~* ^/skins/$ {
if ($arg_name = "") {
return 400;
}
rewrite .* /skins/$arg_name permanent;
}
location ~* ^/cloaks/$ {
if ($arg_name = "") {
return 400;
}
rewrite .* /cloaks/$arg_name permanent;
}

View File

@@ -1,7 +0,0 @@
AddDefaultCharset UTF-8
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(.*)$ index.php?_url=/$1 [QSA,L]
</IfModule>

View File

@@ -1,24 +0,0 @@
<?php
use Phalcon\Mvc\Micro;
error_reporting(E_ALL);
try {
/** @var \Phalcon\Config $config */
$config = include __DIR__ . '/../config/config.php';
/** @var \Phalcon\Loader $loader */
include __DIR__ . '/../config/loader.php';
/** @var Phalcon\DI\FactoryDefault $di */
include __DIR__ . '/../config/services.php';
$app = new Micro($di);
include __DIR__ . '/../app.php';
$app->handle();
} catch (Phalcon\Exception $e) {
echo $e->getMessage();
} catch (PDOException $e) {
echo $e->getMessage();
}

14
version/version.go Normal file
View File

@@ -0,0 +1,14 @@
package version
var (
version = ""
commit = ""
)
func Version() string {
return version
}
func Commit() string {
return commit
}