Compare commits

..

337 Commits
3.0.1 ... v5

Author SHA1 Message Date
ErickSkrauch
dc3d3bb419 Remove left parts of the signer implementation 2024-06-11 04:43:43 +02:00
ErickSkrauch
716ec8bd37 Remove profiles endpoint and textures signing mechanism 2024-06-11 04:35:46 +02:00
ErickSkrauch
62b6ac8083 Fix validation errors formatter 2024-06-10 20:52:45 +02:00
ErickSkrauch
ce6e62ae5c Fix validation errors response during profile upsert 2024-06-03 04:34:35 +02:00
ErickSkrauch
32d749f245 Introduce swagger specification for API 2024-06-03 04:34:00 +02:00
ErickSkrauch
680effa47a Introduce usage metrics for all API endpoints 2024-03-13 01:29:26 +01:00
ErickSkrauch
4e9a145f74 Fixes #39. Merge branch 'otel' into v5 2024-03-05 15:30:16 +01:00
ErickSkrauch
b9a38dd947 Add autoconfiguration for OTEL and resolve TODOs for metrics 2024-03-05 15:14:10 +01:00
ErickSkrauch
7964281f06 Merge branch 'v5' into otel
# Conflicts:
#	go.sum
#	internal/cmd/serve.go
#	internal/http/http.go
2024-03-05 14:26:36 +01:00
ErickSkrauch
528b131309 Read multiline signing private key from the config and remove base64 encoding support 2024-03-05 13:55:31 +01:00
ErickSkrauch
436ff7c294 Implemented API endpoint to sign arbitrary data 2024-03-05 13:07:54 +01:00
ErickSkrauch
f037fb11e1 progress [skip ci] 2024-02-20 02:08:23 +01:00
ErickSkrauch
feb8e32069 Add otel setup 2024-02-14 00:56:48 +01:00
ErickSkrauch
f5bc474b4d Go, Go Context! Added context transfer literally everywhere 2024-02-13 02:08:42 +01:00
ErickSkrauch
fdafbc4f0e Simplify stop signal handling 2024-02-07 18:33:06 +01:00
ErickSkrauch
cecd07c113 Remove dispatcher and eventsubscribers modules, remove statsd integration, remove event bus usage. Overall cleanup before otel integration 2024-02-07 17:34:57 +01:00
ErickSkrauch
5d7a66311d Cleanup cmd runner 2024-02-07 14:29:52 +01:00
ErickSkrauch
d363433c88 Cleanup server error handling 2024-02-07 14:24:41 +01:00
ErickSkrauch
bc4d714112 Make Mojang profiles provider cancellable 2024-02-07 01:36:18 +01:00
ErickSkrauch
10c11bc060 Rework security module, replace JWT library, invalidate JWT tokens signed for Chrly v4, generate RSA key in runtime when not provided via configuration 2024-02-01 12:11:39 +01:00
ErickSkrauch
11340289ad Replace base module name from github.com/elyby/chrly to ely.by/chrly 2024-02-01 08:12:34 +01:00
ErickSkrauch
06afd17557 Fix build for CI 2024-02-01 08:03:38 +01:00
ErickSkrauch
c95ecc2491 Fix go get for CI 2024-02-01 08:01:29 +01:00
ErickSkrauch
77e466cc0d Rework project's structure 2024-02-01 07:58:26 +01:00
ErickSkrauch
dac3ca9001 [BREAKING]
Introduce universal profile entity
Remove fs-based capes serving
Rework management API
Rework Redis storage schema
Reducing amount of the bus emitter usage
2024-01-30 09:05:04 +01:00
ErickSkrauch
dac5e4967f Rewrite mojang textures provider module, cleanup its implementation of events emitter, statsd and etc. 2024-01-10 01:42:10 +01:00
ErickSkrauch
4cdc151ab3 Merge branch 'master' into v5 2023-12-29 07:51:28 +01:00
ErickSkrauch
ad31fdb709 Quick fix for production data inconsistency 2023-12-22 18:45:38 +01:00
ErickSkrauch
fa62d45d00 Update mojang api username filter 2023-12-22 02:00:31 +01:00
ErickSkrauch
cadb89f00a Fixes #40. Allow to upload profile information without a skin
Remove skin file uploading stubs
2023-12-22 01:56:02 +01:00
ErickSkrauch
e568d4cf91 Remove worker mode 2023-12-15 03:42:38 +01:00
ErickSkrauch
20ba78953b Handle absence of the additional reporters 2023-12-14 03:01:55 +01:00
ErickSkrauch
883a7bda3c Fixes #8. Replace radix v2 with v4 2023-12-14 02:16:24 +01:00
ErickSkrauch
d678f61df7 Upgrade dependencies 2023-12-14 02:16:24 +01:00
ErickSkrauch
1543e98b87 Restore codecov export and update README badges 2023-12-13 17:09:46 +01:00
ErickSkrauch
0e0b41d6d7 Update LICENSE 2023-12-13 15:11:07 +01:00
ErickSkrauch
3cd12acc1b Export profile requests metrics to statsd 2023-12-13 01:56:40 +01:00
ErickSkrauch
dac4ed0ac6 Update CHANGELOG 2023-12-13 01:51:22 +01:00
ErickSkrauch
11a779c670 Another version CI fix 2023-12-13 01:34:15 +01:00
ErickSkrauch
6cb5e1eb42 Fix version generation 2023-12-13 01:23:53 +01:00
ErickSkrauch
8959e53270 Generate version and commit refs for built docker image 2023-12-13 01:09:21 +01:00
ErickSkrauch
3526570dd3 Run release only when previous step succeeded 2023-12-12 02:37:52 +01:00
ErickSkrauch
4b7f1346f5 Add an edge tag for master releases 2023-12-12 02:30:27 +01:00
ErickSkrauch
e7721f9e5a Enable pushing 2023-12-12 02:21:22 +01:00
ErickSkrauch
26bfbd1517 Read secrets from secrets 2023-12-12 02:13:19 +01:00
ErickSkrauch
ecbb06b83c Fix release pipeline and upgrade Dockerfile 2023-12-12 02:06:00 +01:00
ErickSkrauch
6accffed45 Replace dep with go mod, migrate from travis to github-actions 2023-12-12 01:35:08 +01:00
ErickSkrauch
980c920ceb Prepare 4.6.0 release 2021-03-04 07:03:07 +01:00
ErickSkrauch
32a9fee3e6 Replace /signature-verification-key endpoint with extension-specific ones /signature-verification-key.{der|pem}.
Fix Content-Disposition header
2021-03-03 13:33:56 +01:00
ErickSkrauch
7cf5ae13be Update Changelog [skip ci] 2021-03-03 01:38:47 +01:00
ErickSkrauch
98d280240e Resolves #28. We didn't manage to catch this error during testing, so we're normalizing its handling and hope that this will not happen again 2021-03-03 01:32:38 +01:00
ErickSkrauch
26042037b6 Increase Mojang's API response timeout 2021-03-03 01:23:34 +01:00
ErickSkrauch
1e3307dcbe Fixes CHRLY-S. Don't return an error for an invalid username 2021-03-03 00:24:31 +01:00
ErickSkrauch
d1d2c7ee6e Merge branch 'sign_textures' 2021-02-27 02:40:13 +01:00
ErickSkrauch
2bc9f8eb57 Rename the signature key param.
Rename the signature verification key endpoint.
Update CHANGELOG and README files
2021-02-27 02:37:59 +01:00
ErickSkrauch
6f148a8791 Implemented /profile/{username} endpoint to get complete profile with signed by the current server textures.
Implemented /signing-key endpoint to get public key in der format, used to sign the textures.
Improved logging of errors from http package.
Changed behavior of the /cloaks endpoint
2021-02-26 02:45:45 +01:00
ErickSkrauch
247499df6a Fixes #29. If a previously cached UUID no longer exists, it will be invalidated and re-requested 2021-02-07 21:19:01 +01:00
ErickSkrauch
3bf6872f3e Resolves CHRLY-H. Add debug statement to investigate #28 2021-02-07 16:47:21 +01:00
ErickSkrauch
60774b6b72 Resolves #27. Replace import of the gock with gopkg.in/h2non/gock.v1 2020-08-21 18:12:31 +03:00
ErickSkrauch
37cc8cda32 Prepare 4.5.0 release 2020-05-01 21:38:18 +03:00
ErickSkrauch
620bb95c74 Fix in_memory_textures_storage_test [skip deploy] 2020-05-01 20:11:49 +03:00
ErickSkrauch
fd05220299 Ensure that queue for Mojang textures provider is initialized before any job will be scheduled 2020-05-01 17:36:37 +03:00
ErickSkrauch
dfe024756e Fix default redis pool size value 2020-05-01 03:57:22 +03:00
ErickSkrauch
66ef76ce6d Handle SIGTERM as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker 2020-05-01 03:06:45 +03:00
ErickSkrauch
aabf54e318 Added new stats reporter to check suitable redis pool size 2020-05-01 02:46:12 +03:00
ErickSkrauch
5dbe6af1d0 Added --cpuprofile flag for the dev Docker images 2020-05-01 00:06:56 +03:00
ErickSkrauch
4c21fc5c90 Implemented health checker for textures provider from Mojang's API 2020-04-30 23:16:22 +03:00
ErickSkrauch
2ea094bbf6 Really fix usernames cache hit events 2020-04-30 00:44:31 +03:00
ErickSkrauch
c4566a337b Rework in_memory_textures_storage. Handle empty properties correctly 2020-04-30 00:24:41 +03:00
ErickSkrauch
05c68c6ba6 Fixes CHRLY-B. Handle the case when the textures property is not presented in Mojang's response 2020-04-29 21:54:40 +03:00
ErickSkrauch
8001eab9db Add rough sentry reporting to catch panic in the mojang textures decoder 2020-04-29 21:15:13 +03:00
ErickSkrauch
33b286cba0 Improve test case for redis.GetUuid 2020-04-28 18:13:01 +03:00
ErickSkrauch
f997fdf9b0 Resolves #26. Rework UUIDs storage interface to simplify results handling 2020-04-28 17:57:51 +03:00
ErickSkrauch
be30c23823 Merge branch '4.5.0' 2020-04-26 22:06:59 +03:00
ErickSkrauch
f43c1a9a37 Resolves #23. Allow to spoof Mojang's API addresses 2020-04-26 21:56:03 +03:00
ErickSkrauch
585318d307 Another attempt to fix FullBus test 2020-04-26 21:05:54 +03:00
ErickSkrauch
b2e501af60 Fix FullBus test 2020-04-26 20:58:46 +03:00
ErickSkrauch
d8f6786c69 Merge pull request #25 from elyby/24_batch_uuids_provider_strategies
FullBus stategy
2020-04-26 18:00:48 +03:00
ErickSkrauch
30c095525c Update README and CHANGELOG 2020-04-26 17:55:02 +03:00
ErickSkrauch
436d98e1a0 Fix stats reporting for batch UUIDs provider 2020-04-26 16:34:46 +03:00
ErickSkrauch
1b9e943c0e Fixed strategies implementations, added tests 2020-04-26 03:48:23 +03:00
ErickSkrauch
29b6bc89b3 Extracted strategy from batch uuids provider implementation.
Reimplemented Periodic strategy.
Implemented FullBus strategy (#24).
Started working on tests.
2020-04-24 19:38:37 +03:00
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
79 changed files with 7233 additions and 477 deletions

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

@@ -0,0 +1,79 @@
name: Build
on:
push:
branches:
- '**'
tags:
- '*.*.*'
pull_request:
jobs:
build:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- id: version
name: Set up build version
run: |
if [[ $GITHUB_REF_TYPE == "tag" ]]; then
VERSION=${GITHUB_REF#refs/tags/}
else
BRANCH_NAME=${GITHUB_REF#refs/heads/}
SHORT_SHA=$(git rev-parse --short $GITHUB_SHA)
VERSION="${BRANCH_NAME}-${SHORT_SHA}"
fi
echo "### Version: $VERSION" >> $GITHUB_STEP_SUMMARY
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Setup Go
uses: actions/setup-go@v5
with:
cache-dependency-path: go.sum
go-version-file: go.mod
- name: Install dependencies
run: go get ./...
- name: Go Format
run: gofmt -s -w . && git diff --exit-code
- name: Go Vet
run: go vet ./...
- name: Go Test
run: go test -v -race --tags redis -coverprofile=coverage.txt -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Build
env:
CGO_ENABLED: 'false'
run: >
go build
-trimpath
-ldflags "-w -s -X ely.by/chrly/internal/version.version=${{ steps.version.outputs.version }} -X ely.by/chrly/internal/version.commit=${{ github.sha }}"
-o ./chrly ./cmd/chrly/...
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: chrly-build-linux-amd64-${{ steps.version.outputs.version }}
path: ./chrly
compression-level: 0

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

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

15
.gitignore vendored
View File

@@ -1,6 +1,11 @@
# IDEA
/.idea
# IDE files
.idea/
*.iml
.vscode
# Docker Compose file
docker-compose.yml
docker-compose.override.yml
# Go mod vendoring
/vendor
# Local environment
/docker-compose.yml
/data

196
CHANGELOG.md Normal file
View File

@@ -0,0 +1,196 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] - xxxx-xx-xx
### Added
- Allow to remove a skin without removing all user information
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.profiles.request`
### Fixed
- Adjusted Mojang usernames filter to be stickier according to their docs
### Changed
- Bumped Go version to 1.21.
### Removed
- Removed mentioning and processing of skin uploading as a file, as this functionality was never implemented and was not planned to be implemented
- StatsD metrics:
- Gauges:
- `ely.skinsystem.{hostname}.app.redis.pool.available`
- Worker mode. Use URL spoofing to load balance outgoing requests.
## [4.6.0] - 2021-03-04
### Added
- `/profile/{username}` endpoint, which returns a profile and its textures, equivalent of the Mojang's
[UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape).
- `/signature-verification-key.der` and `/signature-verification-key.pem` endpoints, which returns the public key in
`DER` or `PEM` formats for signature verification.
### Fixed
- [#28](https://github.com/elyby/chrly/issues/28): Added handling of corrupted data from the Mojang's username to UUID
cache.
- [#29](https://github.com/elyby/chrly/issues/29): If a previously cached UUID no longer exists,
it will be invalidated and re-requested.
- Use correct status code for error about empty response from Mojang's API.
### Changed
- **BREAKING**: `/cloaks/{username}` and `/textures/{username}` endpoints will no longer return a cape if there are no
textures for the requested username.
- All endpoints are now returns `500` status code when an error occurred during request processing.
- Increased the response timeout for Mojang's API from 3 to 10 seconds.
## [4.5.0] - 2020-05-01
### Added
- [#24](https://github.com/elyby/chrly/issues/24): Implemented a new strategy for the queue in the batch provider of
Mojang UUIDs: `full-bus`.
- New configuration param `QUEUE_STRATEGY` with the default value `periodic`.
- New configuration params: `MOJANG_API_BASE_URL` and `MOJANG_SESSION_SERVER_BASE_URL`, that allow you to spoof
Mojang API base addresses.
- New health checker, that ensures that response for textures provider from Mojang's API is valid.
- `dev` Docker images now have the `--cpuprofile` flag, which allows you to run the program with CPU profiling.
- New StatsD metrics:
- Gauges:
- `ely.skinsystem.{hostname}.app.redis.pool.available`
### Fixed
- Handle the case when there is no textures property in Mojang's response.
- Handle `SIGTERM` as a valid stop signal for a graceful shutdown since it's the default stop code for the Docker.
- Default connections pool size for Redis.
### Changed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time` timer will not be recorded if the iteration was
empty.
## [4.4.1] - 2020-04-24
### Added
- [#20](https://github.com/elyby/chrly/issues/20): Print hostname in the `version` command output.
- [#21](https://github.com/elyby/chrly/issues/21): Print Chrly's version during server startup.
### Fixed
- [#22](https://github.com/elyby/chrly/issues/22): Correct version passing during building of the Docker image.
## [4.4.0] - 2020-04-22
### Added
- Mojang textures queue now can be completely disabled via `MOJANG_TEXTURES_ENABLED` param.
- Remote mode for Mojang's textures queue with a new configuration params: `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER` and
`MOJANG_TEXTURES_UUIDS_PROVIDER_URL`.
For example, to send requests directly to [Mojang's APIs](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time),
set the next configuration:
- `MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER=remote`
- `MOJANG_TEXTURES_UUIDS_PROVIDER_URL=https://api.mojang.com/users/profiles/minecraft/`
- Implemented worker mode. The app starts with the only one API endpoint: `/api/worker/mojang-uuid/{username}`,
which is compatible with [Mojang's endpoint](https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time) to exchange
username to its UUID. It can be used with some load balancing software to increase throughput of Mojang's textures
proxy by splitting the load across multiple servers with its own IPs.
- Textures extra param is now can be configured via `TEXTURES_EXTRA_PARAM_NAME` and `TEXTURES_EXTRA_PARAM_VALUE`.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.textures_miss`
- All incoming requests are now logging to the console in
[Apache Common Log Format](http://httpd.apache.org/docs/2.2/logs.html#common).
- Added `/healthcheck` endpoint.
- Graceful server shutdown.
- Panics in http are now logged in Sentry.
### Fixed
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` and
`ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size` are now updates even if the queue is empty.
- Don't return an empty object if Mojang's textures don't contain any skin or cape.
- Provides a correct URL scheme for the cape link.
### Changed
- **BREAKING**: `QUEUE_LOOP_DELAY` param is now sets as a Go duration, not milliseconds.
For example, default value is now `2s500ms`.
- **BREAKING**: Event `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` has been renamed into
`ely.skinsystem.{hostname}.app.mojang_textures.already_scheduled`.
- Bumped Go version to 1.14.
### Removed
- **BREAKING**: `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` counter has been removed.
## [4.3.0] - 2019-11-08
### Added
- 403 Forbidden errors from the Mojang's API are now logged.
- `QUEUE_LOOP_DELAY` configuration param to adjust Mojang's textures queue performance.
### Changed
- Mojang's textures queue loop is now has an iteration delay of 2.5 seconds (was 1).
- Bumped Go version to 1.13.
## [4.2.3] - 2019-10-03
### Changed
- Mojang's textures queue batch size [reduced to 10](https://wiki.vg/index.php?title=Mojang_API&type=revision&diff=14964&oldid=14954).
- 400 BadRequest errors from the Mojang's API are now logged.
## [4.2.2] - 2019-06-19
### Fixed
- GC for in-memory textures cache has not been initialized.
## [4.2.1] - 2019-05-06
### Changed
- Improved Keep-Alive settings for HTTP client used to perform requests to Mojang's APIs.
- Mojang's textures queue now has static delay of 1 second after each iteration to prevent strange `429` errors.
- Mojang's textures queue now caches even errored responses for signed textures to avoid `429` errors.
- Mojang's textures queue now caches textures data for 70 seconds to avoid strange `429` errors.
- Mojang's textures queue now doesn't log timeout errors.
### Fixed
- Panic when Redis connection is broken.
- Duplication of Redis connections pool for Mojang's textures queue.
- Removed validation rules for `hash` field.
## [4.2.0] - 2019-05-02
### Added
- `CHANGELOG.md` file.
- [#1](https://github.com/elyby/chrly/issues/1): Restored Mojang skins proxy.
- New StatsD metrics:
- Counters:
- `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username`
- `ely.skinsystem.{hostname}.app.mojang_textures.request`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit_nil`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queued`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_miss`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.cache_hit`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request`
- Gauges:
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size`
- Timers:
- `ely.skinsystem.{hostname}.app.mojang_textures.result_time`
- `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time`
- `ely.skinsystem.{hostname}.app.mojang_textures.textures.request_time`
### Changed
- Bumped Go version to 1.12.
- Bumped Alpine version to 3.9.3.
### Fixed
- `/textures` request no longer proxies request to Mojang in a case when there is no information about the skin,
but there is a cape.
- [#5](https://github.com/elyby/chrly/issues/5): Return Redis connection to the pool after commands are executed
### Removed
- `hash` field from `/textures` response because the game doesn't use it and calculates hash by getting the filename
from the textures link instead.
- `hash` field from `POST /api/skins` endpoint.
[Unreleased]: https://github.com/elyby/chrly/compare/4.6.0...HEAD
[4.6.0]: https://github.com/elyby/chrly/compare/4.5.0...4.6.0
[4.5.0]: https://github.com/elyby/chrly/compare/4.4.1...4.5.0
[4.4.1]: https://github.com/elyby/chrly/compare/4.4.0...4.4.1
[4.4.0]: https://github.com/elyby/chrly/compare/4.3.0...4.4.0
[4.3.0]: https://github.com/elyby/chrly/compare/4.2.3...4.3.0
[4.2.3]: https://github.com/elyby/chrly/compare/4.2.2...4.2.3
[4.2.2]: https://github.com/elyby/chrly/compare/4.2.1...4.2.2
[4.2.1]: https://github.com/elyby/chrly/compare/4.2.0...4.2.1
[4.2.0]: https://github.com/elyby/chrly/compare/4.1.1...4.2.0

View File

@@ -1,18 +0,0 @@
FROM golang:1.7
RUN mkdir -p /go/src/elyby/minecraft-skinsystem \
&& ln -s /go/src/elyby/minecraft-skinsystem /go/src/app
WORKDIR /go/src/app
COPY ./minecraft-skinsystem.go /go/src/app/
COPY ./lib /go/src/app/lib
RUN go-wrapper download
RUN go-wrapper install
EXPOSE 80
VOLUME ["/go/src/app"]
CMD ["go-wrapper", "run"]

201
LICENSE Normal file
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 2023 Ely.by (https://ely.by)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

463
README.md
View File

@@ -1,38 +1,459 @@
# Это заготовка для нормального файла
# Chrly
Для настройки Dev-окружения нужно склонировать проект в удобное место,
за тем сделать символьную ссылку в свой GOPATH:
[![Written in Go][ico-lang]][link-go]
[![Build Status][ico-build]][link-build]
[![Coverage][ico-coverage]][link-coverage]
[![Keep a Changelog][ico-changelog]](CHANGELOG.md)
[![Software License][ico-license]](LICENSE)
```sh
# Выполнять, находясь внутри директории репозитория
mkdir -p $GOPATH/src/elyby
ln -s $PWD $GOPATH/src/elyby/minecraft-skinsystem
Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's
skins system. It's packaged and distributed as a Docker image and can be downloaded from
[Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can withstand heavy loads and is
production ready.
## Installation
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY`
environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
below.
```yml
version: '2'
services:
app:
image: elyby/chrly
hostname: chrly0
restart: always
links:
- redis
volumes:
- ./data/capes:/data/capes
ports:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
redis:
image: redis:4.0-32bit
restart: always
volumes:
- ./data/redis:/data
```
Или можно склонировать репозиторий сразу в нужную локацию:
**Tip**: to generate a value for the `CHRLY_SIGNING_KEY` use the command below and then join it with a `base64:` prefix.
```sh
git clone git@bitbucket.org:elyby/minecraft-skinsystem.git $GOPATH/src/elyby/minecraft-skinsystem
openssl genrsa 4096 | base64 -w0
```
Нужно скопировать правильный docker-compose файл для желаемого окружения:
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations.
### Config
Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key
inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated.
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `up`
it again:
```sh
cp docker-compose.dev.yml docker-compose.yml # dev env
cp docker-compose.prod.yml docker-compose.yml # prod env
docker-compose up -d app
```
И за тем всё это поднять:
**Variables to adjust:**
<table>
<thead>
<tr>
<th>ENV</th>
<th>Description</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>STORAGE_REDIS_HOST</td>
<td>
By default, Chrly tries to connect to the <code>redis</code> host
(by service name in docker-compose configuration).
</td>
<td><code>localhost</code></td>
</tr>
<tr>
<td>STORAGE_REDIS_PORT</td>
<td>
Specifies the Redis connection port.
</td>
<td><code>6379</code></td>
</tr>
<tr>
<td>STORAGE_REDIS_POOL</td>
<td>By default, Chrly creates pool with 10 connection, but you may want to increase it</td>
<td><code>20</code></td>
</tr>
<tr>
<td>STATSD_ADDR</td>
<td>StatsD can be used to collect metrics</td>
<td><code>localhost:8125</code></td>
</tr>
<tr>
<td>SENTRY_DSN</td>
<td>Sentry can be used to collect app errors</td>
<td><code>https://public:private@your.sentry.io/1</code></td>
</tr>
<tr>
<td>QUEUE_STRATEGY</td>
<td>
Sets the strategy for the queue in the batch provider of Mojang UUIDs. Allowed values are <code>periodic</code>
and <code>full-bus</code> (see <a href="https://github.com/elyby/chrly/issues/24">#24</a>).
</td>
<td><code>periodic</code></td>
</tr>
<tr>
<td>QUEUE_LOOP_DELAY</td>
<td>
Parameter is sets the delay before each iteration of the Mojang's textures queue
(<a href="https://golang.org/pkg/time/#ParseDuration">Go's duration</a>)
</td>
<td><code>3s200ms</code></td>
</tr>
<tr>
<td>QUEUE_BATCH_SIZE</td>
<td>
Sets the count of usernames, which will be sent to the
<a href="https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs">Mojang's API to exchange them to their UUIDs</a>.
The current limit is <code>10</code>, but it may change in the future, so you may want to adjust it.
</td>
<td><code>10</code></td>
</tr>
<tr>
<td>MOJANG_TEXTURES_ENABLED</td>
<td>
Allows to completely disable Mojang textures provider for unknown usernames. Enabled by default.
</td>
<td><code>true</code></td>
</tr>
<tr>
<td>MOJANG_API_BASE_URL</td>
<td>
Allows you to spoof the Mojang's API server address.
</td>
<td><code>https://api.mojang.com</code></td>
</tr>
<tr>
<td>MOJANG_SESSION_SERVER_BASE_URL</td>
<td>
Allows you to spoof the Mojang's Session server address.
</td>
<td><code>https://sessionserver.mojang.com</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_NAME</td>
<td>
Sets the name of the extra property in the
<a href="#get-texturessignedusername">signed textures</a> response.
</td>
<td><code>your-name</code></td>
</tr>
<tr>
<td>TEXTURES_EXTRA_PARAM_VALUE</td>
<td>
Sets the value of the extra property in the
<a href="#get-texturessignedusername">signed textures</a> response.
</td>
<td><code>your awesome joke!</code></td>
</tr>
</tbody>
</table>
If something goes wrong, you can always access logs by executing `docker-compose logs -f app`.
## Endpoints
Each endpoint that accepts `username` as a part of an url takes it case-insensitive. The `.png` postfix can be omitted.
#### `GET /skins/{username}.png`
This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll
respond with the `301` redirect to that url. If the skin entry isn't found, it'll request textures information from
Mojang's API and if it has a skin, than it'll return a `301` redirect to it.
#### `GET /cloaks/{username}.png`
It responds to requested `username` with a cape texture. If the cape entry isn't found, it'll request textures
information from Mojang's API and if it has a cape, than it'll return a `301` redirect to it.
#### `GET /textures/{username}`
This endpoint forms response payloads as if it was the `textures`' property, but without base64 encoding. For example:
```json
{
"SKIN": {
"url": "http://example.com/skin.png",
"metadata": {
"model": "slim"
}
},
"CAPE": {
"url": "http://example.com/cape.png"
}
}
```
If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has
a textures property, than it'll return decoded contents.
That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
to the Chrly server and put the result in your hasJoined response.
#### `GET /profile/{username}`
This endpoint behaves exactly like the
[Mojang's UUID -> Profile + Skin/Cape endpoint](https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape), but using
a username instead of the UUID. Just like in the Mojang's API, you can append `?unsigned=false` part to URL to sign
the `textures` property. If the textures for the requested username aren't found, it'll request them through the
Mojang's API, but the Mojang's signature will be discarded and the textures will be re-signed using the signature key
for your Chrly instance.
Response example:
```json
{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "username",
"properties": [
{
"name": "textures",
"signature": "signature value",
"value": "base64 encoded value"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}
```
The base64 `value` string for the `textures` property decoded:
```json
{
"timestamp": 1614387238630,
"profileId": "0f657aa8bfbe415db7005750090d3af3",
"profileName": "username",
"textures": {
"SKIN": {
"url": "http://example.com/skin.png"
},
"CAPE": {
"url": "http://example.com/cape.png"
}
}
}
```
If username can't be found locally and can't be obtained from the Mojang's API, empty response with `204` status code
will be sent.
Note that this endpoint will try to use the UUID for the stored profile in the database. This is an edge case, related
to the situation where the user is available in the database but has no textures, which caused them to be retrieved
from the Mojang's API.
#### `GET /signature-verification-key.der`
This endpoint returns a public key that can be used to verify textures signatures. The key is provided in `DER` format,
so it can be used directly in the Authlib, without modifying the signature checking algorithm.
#### `GET /signature-verification-key.pem`
The same endpoint as the previous one, except that it returns the key in `PEM` format.
#### `GET /textures/signed/{username}`
Actually, this is the [Ely.by](https://ely.by)'s feature called
[Server Skins System](https://ely.by/server-skins-system), but if you have your own source of Mojang's signatures,
then you can pass it with textures and it'll be displayed in response of this endpoint. Received response should be
directly sent to the client without any modification via game server API.
Response example:
```json
{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "username",
"properties": [
{
"name": "textures",
"signature": "signature value",
"value": "base64 encoded value"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}
```
If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent.
You can adjust URL to `/textures/signed/{username}?proxy=true` to obtain textures information for provided username
from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in
the example above.
#### `GET /skins?name={username}`
Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username
placeholder wasn't used.
#### `GET /cloaks?name={username}`
Equivalent of the `GET /cloaks/{username}.png`, but constructed especially for old Minecraft versions, where username
placeholder wasn't used.
### Records manipulating API
Each request to the internal API should be performed with the Bearer authorization header. Example curl request:
```sh
curl -X POST -i http://chrly.domain.com/api/skins \
-H "Authorization: Bearer Ym9zY236Ym9zY28="
```
You can obtain token by executing `docker-compose run --rm app token`.
#### `POST /api/skins`
Endpoint allows you to create or update skin record for a username.
The request body must be encoded as `application/x-www-form-urlencoded`.
**Request params:**
| Field | Type | Description |
|-----------------|--------|--------------------------------------------------------------------------------|
| identityId | int | Unique record identifier. |
| username | string | Username. Case insensitive. |
| uuid | uuid | UUID of the user. |
| skinId | int | Skin identifier. |
| is1_8 | bool | Does the skin have the new format (64x64). |
| isSlim | bool | Does skin have slim arms (Alex model). |
| mojangTextures | string | Mojang textures field. It must be a base64 encoded json string. Not required. |
| mojangSignature | string | Signature for Mojang textures, which is required when `mojangTextures` passed. |
| url | string | Actual url of the skin. |
**Important**: all parameters are always read at least as their default values. So, if you only want to update the username and not pass the skin data it will reset all skin information. If you want to keep the data, you should always pass the full set of parameters.
If successful you'll receive `201` status code. In the case of failure there will be `400` status code and errors list
as json:
```json
{
"errors": {
"identityId": [
"The identityId field must be numeric"
]
}
}
```
#### `DELETE /api/skins/id:{identityId}`
Performs record removal by identity id. Request body is not required. On success you will receive `204` status code.
On failure it'll be `404` with the json body:
```json
{
"error": "Cannot find record for requested user id"
}
```
#### `DELETE /api/skins/{username}`
Same endpoint as above but it removes record by identity's username. Have the same behavior, but in case of failure
response will be:
```json
{
"error": "Cannot find record for requested username"
}
```
### Health check
#### `GET /healthcheck`
This endpoint can be used to programmatically check the status of the server.
If all internal checks are successful, the server will return `200` status code with the following body:
```json
{
"status": "OK"
}
```
If any of the checks fails, the server will return `503` status code with the following body:
```json
{
"status": "Service Unavailable",
"errors": {
"mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded"
}
}
```
## Development
First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH`
environment variable.
Then you must fork this repository. Now follow these steps:
```sh
# Get the source code
git clone https://github.com/elyby/chrly.git
# Switch to the project folder
cd chrly
# Install dependencies
go mod download
# Add your fork link as a remote
git remote add fork git@github.com:your-username/chrly.git
# Create a new branch for your task
git checkout -b iss-123
```
You only need to execute `go run main.go` to run the project, but without Redis database and a secret key it won't work
for very long. You have to export `CHRLY_SECRET` environment variable globally or pass it via `env`:
```sh
env CHRLY_SECRET=some_local_secret go run main.go serve
```
Redis can be installed manually, but if you have [Docker installed](https://docs.docker.com/install/), you can run
predefined docker-compose service. Simply execute the next commands:
```sh
cp docker-compose.dev.yml docker-compose.yml
docker-compose up -d
```
Если нужно пересобрать весь контейнер, то выполняем следующее:
If your Redis instance isn't located at the `localhost`, you can change host by editing environment variable
`STORAGE_REDIS_HOST`.
```
docker-compose stop app # Останавливаем конейтнер, если он ещё работает
docker-compose rm -f app # Удаляем конейтнер
docker-compose build app # Запускаем билд по новой
docker-compose up -d app # Поднимаем свежесобранный контейнер обратно
```
After all of that `go run main.go serve` should successfully start the application.
To run tests execute `go test ./...`.
[ico-lang]: https://img.shields.io/github/go-mod/go-version/elyby/chrly?style=flat-square
[ico-build]: https://img.shields.io/github/actions/workflow/status/elyby/chrly/build.yml?style=flat-square
[ico-coverage]: https://img.shields.io/codecov/c/github/elyby/chrly.svg?style=flat-square
[ico-changelog]: https://img.shields.io/badge/keep%20a-changelog-orange.svg?style=flat-square
[ico-license]: https://img.shields.io/github/license/elyby/chrly.svg?style=flat-square
[link-go]: https://golang.org
[link-build]: https://github.com/elyby/chrly/actions
[link-coverage]: https://codecov.io/gh/elyby/chrly

12
build/package/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
# syntax=docker/dockerfile:1
ARG BINARY
FROM scratch
EXPOSE 80
COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY ${BINARY} /usr/local/bin/chrly
ENTRYPOINT ["/usr/local/bin/chrly"]
CMD ["serve"]

16
cmd/chrly/chrly.go Normal file
View File

@@ -0,0 +1,16 @@
package main
import (
"fmt"
"os"
. "ely.by/chrly/internal/cmd"
)
func main() {
err := RootCmd.Execute()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}

View File

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

View File

@@ -0,0 +1,16 @@
version: '3'
services:
chrly:
image: elyby/chrly:latest
restart: always
ports:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
STORAGE_REDIS_HOST: redis
redis:
image: redis:latest
restart: always
volumes:
- ./data/redis:/data

View File

@@ -1,10 +0,0 @@
version: '2'
services:
app:
ports:
- "80:80"
redis:
image: redis:3.0
volumes:
- ./data/redis:/data

View File

@@ -1,17 +0,0 @@
version: '2'
services:
app:
extends:
file: docker-compose.base.yml
service: app
build: .
volumes:
- ./:/go/src/app
command: ["go", "run", "minecraft-skinsystem.go"]
links:
- redis
redis:
extends:
file: docker-compose.base.yml
service: redis

View File

@@ -1,16 +0,0 @@
version: '2'
services:
app:
extends:
file: docker-compose.base.yml
service: app
image: erickskrauch/ely-by-skinsystem:master
links:
- redis
restart: always
redis:
extends:
file: docker-compose.base.yml
service: redis
restart: always

167
docs/swagger.yml Normal file
View File

@@ -0,0 +1,167 @@
openapi: 3.0.0
info:
title: Chrly
version: v5
servers:
- url: http://skinsystem.ely.by
description: Ely.by's production server
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
responses:
UnauthorizedError:
description: Access token is missing or invalid
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: the token doesn't have the scope to perform the action
paths:
/api/profiles:
post:
operationId: upsertProfile
summary: Upsert player's profile.
description: >
Creates a new user profile or updates an existing one.
The user is identified by their UUID.
If several users with different UUIDs try to occupy the same username, the last one wins.
tags:
- profiles
- api
security:
- BearerAuth: [profiles]
requestBody:
content:
application/x-www-form-urlencoded:
schema:
required:
- uuid
- username
properties:
uuid:
type: string
example: 8cd2c16e-7ef3-4fa1-87ea-6e602bffd7c7
username:
type: string
example: ErickSkrauch
skinUrl:
type: string
example: https://example.com/skin.png
skinModel:
type: string
enum:
- steve
- slim
example: slim
capeUrl:
type: string
example: https://example.com/cape.png
mojangTextures:
type: string
example: eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0
mojangSignature:
description: Required when the `mojangTextures` parameter is present in the request.
type: string
example: QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=
responses:
201:
description: The profiles has been successfully upserted.
400:
description: Some fields doesn't pass the validation.
content:
application/json:
schema:
type: object
properties:
errors:
type: object
properties:
body:
type: array
items:
type: string
enum:
- The body of the request must be a valid url-encoded string
username:
type: array
items:
type: string
enum:
- Username is a required field
- Username must be a valid username
- Username must be a maximum of 21 in length
uuid:
type: array
items:
type: string
enum:
- Uuid is a required field
- Uuid must be a valid UUID
skinUrl:
type: array
items:
type: string
enum:
- SkinUrl must be a valid URL
skinModel:
type: array
items:
type: string
enum:
- SkinModel must be a maximum of 20 in length
capeUrl:
type: array
items:
type: string
enum:
- CapeUrl must be a valid URL
mojangTextures:
type: array
items:
type: string
enum:
- MojangTextures must be a valid Base64 string
mojangSignature:
type: array
items:
type: string
enum:
- MojangSignature is a required field
- MojangSignature must be a valid Base64 string
401:
$ref: "#/components/responses/UnauthorizedError"
/api/profiles/{uuid}:
delete:
operationId: deleteProfile
summary: Deletes a player's profile by its UUID.
description: Returns a successful response even if the profile did not previously exist.
tags:
- profiles
- api
parameters:
- name: uuid
in: query
required: true
description: The UUID can be passed with or without dashes, upper or lower cased.
example: 8cd2c16e-7ef3-4fa1-87ea-6e602bffd7c7
schema:
type: string
minimum: 500
security:
- BearerAuth: [ profiles ]
responses:
204:
description: The profiles has been successfully deleted.
401:
$ref: "#/components/responses/UnauthorizedError"

98
go.mod Normal file
View File

@@ -0,0 +1,98 @@
module ely.by/chrly
go 1.21
// Main dependencies
require (
github.com/agoda-com/opentelemetry-go/otelslog v0.1.1
github.com/agoda-com/opentelemetry-logs-go v0.4.3
github.com/brunomvsouza/singleflight v0.4.0
github.com/defval/di v1.12.0
github.com/etherlabsio/healthcheck/v2 v2.0.0
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea
github.com/go-playground/validator/v10 v10.17.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/gorilla/mux v1.8.1
github.com/huandu/xstrings v1.4.0
github.com/jellydator/ttlcache/v3 v3.1.1
github.com/mediocregopher/radix/v4 v4.1.4
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.1
github.com/valyala/fastjson v1.6.4
go.opentelemetry.io/contrib/exporters/autoexport v0.49.0
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.49.0
go.opentelemetry.io/contrib/instrumentation/runtime v0.49.0
go.opentelemetry.io/otel v1.24.0
go.opentelemetry.io/otel/metric v1.24.0
go.opentelemetry.io/otel/sdk v1.24.0
go.opentelemetry.io/otel/sdk/metric v1.24.0
go.opentelemetry.io/otel/trace v1.24.0
go.uber.org/multierr v1.11.0
)
// Dev dependencies
require (
github.com/h2non/gock v1.2.0
github.com/stretchr/testify v1.8.4
)
// Indirect dependencies
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.18.0 // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tilinna/clock v1.0.2 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.46.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect
go.opentelemetry.io/proto/otlp v1.1.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014 // indirect
google.golang.org/grpc v1.61.1 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

217
go.sum Normal file
View File

@@ -0,0 +1,217 @@
github.com/agoda-com/opentelemetry-go/otelslog v0.1.1 h1:6nV8PZCzySHuh9kP/HZ2OJqGucwQiM+yZRugKDvtzj4=
github.com/agoda-com/opentelemetry-go/otelslog v0.1.1/go.mod h1:CSc0veIcY/HsIfH7l5PGtIpRvBttk09QUQlweVkD2PI=
github.com/agoda-com/opentelemetry-logs-go v0.4.3 h1:dYAx/q9di+/Pv6HuGq59DFIOjqKT0LTy3PYTIz8ccq8=
github.com/agoda-com/opentelemetry-logs-go v0.4.3/go.mod h1:gPQ0fHqroxNP2DlQFZt29/pfqGiP2m6Q5CCxEgLo6yQ=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/brunomvsouza/singleflight v0.4.0 h1:9dNcTeYoXSus3xbZEM0EEZ11EcCRjUZOvVW8rnDMG5Y=
github.com/brunomvsouza/singleflight v0.4.0/go.mod h1:8RYo9j5WQRupmsnUz5DlUWZxDLNi+t9Zhj3EZFmns7I=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d h1:S2NE3iHSwP0XV47EEXL8mWmRdEfGscSJ+7EgePNgt0s=
github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/defval/di v1.12.0 h1:xXm7BMX2+Nr0Yyu55DeJl/rmfCA7CQX89f4AGE0zA6U=
github.com/defval/di v1.12.0/go.mod h1:PhVbOxQOvU7oawTOJXXTvqOJp1Dvsjs5PuzMw9gGl0I=
github.com/etherlabsio/healthcheck/v2 v2.0.0 h1:oKq8cbpwM/yNGPXf2Sff6MIjVUjx/pGYFydWzeK2MpA=
github.com/etherlabsio/healthcheck/v2 v2.0.0/go.mod h1:huNVOjKzu6FI1eaO1CGD3ZjhrmPWf5Obu/pzpI6/wog=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea h1:t6e33/eet/VyiHHHKs0cBytUISUWQ/hmQwOlqtFoGEo=
github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74=
github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM=
github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE=
github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU=
github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.1.1 h1:RCgYJqo3jgvhl+fEWvjNW8thxGWsgxi+TPhRir1Y9y8=
github.com/jellydator/ttlcache/v3 v3.1.1/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts=
github.com/mediocregopher/radix/v4 v4.1.4/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos=
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM=
github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI=
github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
go.opentelemetry.io/contrib/exporters/autoexport v0.49.0 h1:SPuRs5SgCd9loXBBY5HuZsyuweowIs6ADg9UtStEv+k=
go.opentelemetry.io/contrib/exporters/autoexport v0.49.0/go.mod h1:BDsrww+PTgwfvBjsZQMstsE1n5dS3hDCtAfYG1t3wag=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.48.0 h1:7rkdNoXgScpSUIqBch/VOB24fk9g0wl3Tr5WPtshi9o=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.48.0/go.mod h1:U3t9uswWhDzieXHMNWP6zk87J4HNondiibKMdNLpnMk=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.49.0 h1:h+c4WbSjBBc3j+IsxwB2mWvkm2nDh0SyGLa5Y5+V9cw=
go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux v0.49.0/go.mod h1:FObmJ0epY1FcwMR7aq7sRkrCfwwV3d0GBGFfyV5JUBg=
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0 h1:dJlCKeq+zmO5Og4kgxqPvvJrzuD/mygs1g/NYM9dAsU=
go.opentelemetry.io/contrib/instrumentation/runtime v0.48.0/go.mod h1:p+hpBCpLHpuUrR0lHgnHbUnbCBll1IhrcMIlycC+xYs=
go.opentelemetry.io/contrib/instrumentation/runtime v0.49.0 h1:dg9y+7ArpumB6zwImJv47RHfdgOGQ1EMkzP5vLkEnTU=
go.opentelemetry.io/contrib/instrumentation/runtime v0.49.0/go.mod h1:Ul4MtXqu/hJBM+v7a6dCF0nHwckPMLpIpLeCi4+zfdw=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.24.0 h1:f2jriWfOdldanBwS9jNBdeOKAQN7b4ugAMaNu1/1k9g=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.24.0/go.mod h1:B+bcQI1yTY+N0vqMpoZbEN7+XU4tNM0DmUiOwebFJWI=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.24.0 h1:mM8nKi6/iFQ0iqst80wDHU2ge198Ye/TfN0WBS5U24Y=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.24.0/go.mod h1:0PrIIzDteLSmNyxqcGYRL4mDIo8OTuBAOI/Bn1URxac=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/exporters/prometheus v0.46.0 h1:I8WIFXR351FoLJYuloU4EgXbtNX2URfU/85pUPheIEQ=
go.opentelemetry.io/otel/exporters/prometheus v0.46.0/go.mod h1:ztwVUHe5DTR/1v7PeuGRnU5Bbd4QKYwApWmuutKsJSs=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0 h1:JYE2HM7pZbOt5Jhk8ndWZTUWYOVift2cHjXVMkPdmdc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.24.0/go.mod h1:yMb/8c6hVsnma0RpsBMNo0fEiQKeclawtgaIaOp2MLY=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/sdk/metric v1.24.0 h1:yyMQrPzF+k88/DbH7o4FMAs80puqd+9osbiBrJrz/w8=
go.opentelemetry.io/otel/sdk/metric v1.24.0/go.mod h1:I6Y5FjH6rvEnTTAYQz3Mmv2kl6Ek5IIrmwTLqMrrOE0=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8=
golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe h1:USL2DhxfgRchafRvt/wYyyQNzwgL7ZiURcozOE/Pkvo=
google.golang.org/genproto v0.0.0-20240125205218-1f4bbc51befe/go.mod h1:cc8bqMqtv9gMOr0zHg2Vzff5ULhhL2IXP4sbcn32Dro=
google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014 h1:x9PwdEgd11LgK+orcck69WVRo7DezSO4VUMPI4xpc8A=
google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014 h1:FSL3lRCkhaPFxqi0s9o+V4UI2WTzAVOvkgbd4kVV4Wg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240205150955-31a09d347014/go.mod h1:SaPjaZGWb0lPqs6Ittu0spdfrOArqji4ZdeP5IC/9N4=
google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY=
google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

37
internal/cmd/root.go Normal file
View File

@@ -0,0 +1,37 @@
package cmd
import (
"strings"
. "github.com/defval/di"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"ely.by/chrly/internal/di"
"ely.by/chrly/internal/version"
)
var RootCmd = &cobra.Command{
Use: "chrly",
Short: "Implementation of the Minecraft skins system server",
Version: version.Version(),
}
func shouldGetContainer() *Container {
container, err := di.New()
if err != nil {
panic(err)
}
return container
}
func init() {
cobra.OnInitialize(initConfig)
}
func initConfig() {
viper.AutomaticEnv()
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
}

View File

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

63
internal/cmd/serve.go Normal file
View File

@@ -0,0 +1,63 @@
package cmd
import (
"context"
"log/slog"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"ely.by/chrly/internal/di"
"ely.by/chrly/internal/http"
"ely.by/chrly/internal/otel"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Starts HTTP handler for the skins system",
RunE: func(cmd *cobra.Command, args []string) error {
return startServer(di.ModuleSkinsystem, di.ModuleProfiles)
},
}
func init() {
RootCmd.AddCommand(serveCmd)
}
func startServer(modules ...string) error {
container := shouldGetContainer()
var globalCtx context.Context
err := container.Resolve(&globalCtx)
if err != nil {
return err
}
var config *viper.Viper
err = container.Resolve(&config)
if err != nil {
return err
}
if !config.GetBool("otel.sdk.disabled") {
shutdownOtel, err := otel.SetupOTelSDK(globalCtx)
defer func() {
err := shutdownOtel(context.Background())
if err != nil {
slog.Error("Unable to shutdown OpenTelemetry", slog.Any("error", err))
}
}()
if err != nil {
return err
}
}
config.Set("modules", modules)
err = container.Invoke(http.StartServer)
if err != nil {
return err
}
return nil
}

42
internal/cmd/token.go Normal file
View File

@@ -0,0 +1,42 @@
package cmd
import (
"fmt"
"ely.by/chrly/internal/security"
"github.com/spf13/cobra"
)
var tokenCmd = &cobra.Command{
Use: "token scope1 ...",
Example: "token profiles sign",
Short: "Creates a new token, which allows to interact with Chrly API",
ValidArgs: []string{string(security.ProfilesScope), string(security.SignScope)},
RunE: func(cmd *cobra.Command, args []string) error {
container := shouldGetContainer()
var auth *security.Jwt
err := container.Resolve(&auth)
if err != nil {
return err
}
scopes := make([]security.Scope, len(args))
for i := range args {
scopes[i] = security.Scope(args[i])
}
token, err := auth.NewToken(scopes...)
if err != nil {
return fmt.Errorf("Unable to create a new token. The error is %v\n", err)
}
fmt.Println(token)
return nil
},
}
func init() {
RootCmd.AddCommand(tokenCmd)
}

32
internal/cmd/version.go Normal file
View File

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

18
internal/db/model.go Normal file
View File

@@ -0,0 +1,18 @@
package db
type Profile struct {
// Uuid contains user's UUID without dashes in lower case
Uuid string
// Username contains user's username with the original casing
Username string
// SkinUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a skin
SkinUrl string
// SkinModel contains skin's model. It will be empty when the model is default
SkinModel string
// CapeUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a cape
CapeUrl string
// MojangTextures contains the original textures value from Mojang's skinsystem
MojangTextures string
// MojangSignature contains the original textures signature from Mojang's skinsystem
MojangSignature string
}

205
internal/db/redis/redis.go Normal file
View File

@@ -0,0 +1,205 @@
package redis
import (
"context"
"fmt"
"strings"
"github.com/mediocregopher/radix/v4"
"ely.by/chrly/internal/db"
)
const usernameToProfileKey = "hash:username-to-profile"
const userUuidToUsernameKey = "hash:uuid-to-username"
type Redis struct {
client radix.Client
serializer db.ProfileSerializer
}
func New(ctx context.Context, profileSerializer db.ProfileSerializer, addr string, poolSize int) (*Redis, error) {
client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr)
if err != nil {
return nil, err
}
return &Redis{
client: client,
serializer: profileSerializer,
}, nil
}
func (r *Redis) FindProfileByUsername(ctx context.Context, username string) (*db.Profile, error) {
var profile *db.Profile
err := r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
profile, err = r.findProfileByUsername(ctx, conn, username)
return err
}))
return profile, err
}
func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) {
var encodedResult []byte
err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username)))
if err != nil {
return nil, err
}
if len(encodedResult) == 0 {
return nil, nil
}
return r.serializer.Deserialize(encodedResult)
}
func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) {
var username string
return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid)))
}
func (r *Redis) SaveProfile(ctx context.Context, profile *db.Profile) error {
return r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return r.saveProfile(ctx, conn, profile)
}))
}
func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error {
newUsernameHashKey := usernameHashKey(profile.Username)
existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid)
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
// If user has changed username, then we must delete his old username record
if existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey))
if err != nil {
return err
}
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey))
if err != nil {
return err
}
serializedProfile, err := r.serializer.Serialize(profile)
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile))
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "EXEC"))
if err != nil {
return err
}
return nil
}
func (r *Redis) RemoveProfileByUuid(ctx context.Context, uuid string) error {
return r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return r.removeProfileByUuid(ctx, conn, uuid)
}))
}
func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error {
username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid)
if err != nil {
return err
}
err = conn.Do(ctx, radix.Cmd(nil, "MULTI"))
if err != nil {
return err
}
err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", userUuidToUsernameKey, normalizeUuid(uuid)))
if err != nil {
return err
}
if username != "" {
err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username)))
if err != nil {
return err
}
}
return conn.Do(ctx, radix.Cmd(nil, "EXEC"))
}
func (r *Redis) GetUuidForMojangUsername(ctx context.Context, username string) (string, string, error) {
var uuid string
foundUsername := username
err := r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
var err error
uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username)
return err
}))
return uuid, foundUsername, err
}
func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) {
key := buildMojangUsernameKey(username)
var result string
err := conn.Do(ctx, radix.Cmd(&result, "GET", key))
if err != nil {
return "", "", err
}
if result == "" {
return "", "", nil
}
parts := strings.Split(result, ":")
return parts[1], parts[0], nil
}
func (r *Redis) StoreMojangUuid(ctx context.Context, username string, uuid string) error {
return r.client.Do(ctx, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error {
return storeMojangUuid(ctx, conn, username, uuid)
}))
}
func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error {
value := fmt.Sprintf("%s:%s", username, uuid)
err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30))
if err != nil {
return err
}
return nil
}
func (r *Redis) Ping(ctx context.Context) error {
return r.client.Do(ctx, radix.Cmd(nil, "PING"))
}
func normalizeUuid(uuid string) string {
return strings.ToLower(strings.ReplaceAll(uuid, "-", ""))
}
func usernameHashKey(username string) string {
return strings.ToLower(username)
}
func buildMojangUsernameKey(username string) string {
return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username))
}

View File

@@ -0,0 +1,281 @@
//go:build redis
package redis
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"testing"
"github.com/mediocregopher/radix/v4"
"github.com/stretchr/testify/mock"
assert "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"ely.by/chrly/internal/db"
)
var redisAddr string
func init() {
host := "localhost"
port := 6379
if os.Getenv("STORAGE_REDIS_HOST") != "" {
host = os.Getenv("STORAGE_REDIS_HOST")
}
if os.Getenv("STORAGE_REDIS_PORT") != "" {
port, _ = strconv.Atoi(os.Getenv("STORAGE_REDIS_PORT"))
}
redisAddr = fmt.Sprintf("%s:%d", host, port)
}
type MockProfileSerializer struct {
mock.Mock
}
func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) {
args := m.Called(profile)
return []byte(args.String(0)), args.Error(1)
}
func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) {
args := m.Called(value)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
func TestNew(t *testing.T) {
t.Run("should connect", func(t *testing.T) {
conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12)
assert.Nil(t, err)
assert.NotNil(t, conn)
})
t.Run("should return error", func(t *testing.T) {
conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution
assert.Error(t, err)
assert.Nil(t, conn)
})
}
type redisTestSuite struct {
suite.Suite
Redis *Redis
Serializer *MockProfileSerializer
cmd func(cmd string, args ...interface{}) string
}
func (s *redisTestSuite) SetupSuite() {
s.Serializer = &MockProfileSerializer{}
ctx := context.Background()
conn, err := New(ctx, s.Serializer, redisAddr, 10)
if err != nil {
panic(fmt.Errorf("cannot establish connection to redis: %w", err))
}
s.Redis = conn
s.cmd = func(cmd string, args ...interface{}) string {
var result string
err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...))
if err != nil {
panic(err)
}
return result
}
}
func (s *redisTestSuite) SetupSubTest() {
// Cleanup database before each test
s.cmd("FLUSHALL")
}
func (s *redisTestSuite) TearDownSubTest() {
s.Serializer.AssertExpectations(s.T())
for _, call := range s.Serializer.ExpectedCalls {
call.Unset()
}
}
func TestRedis(t *testing.T) {
suite.Run(t, new(redisTestSuite))
}
func (s *redisTestSuite) TestFindProfileByUsername() {
ctx := context.Background()
s.Run("exists record", func() {
serializedData := []byte("mock.exists.profile")
expectedProfile := &db.Profile{}
s.cmd("HSET", usernameToProfileKey, "mock", serializedData)
s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil)
profile, err := s.Redis.FindProfileByUsername(ctx, "Mock")
s.Require().NoError(err)
s.Require().Same(expectedProfile, profile)
})
s.Run("not exists record", func() {
profile, err := s.Redis.FindProfileByUsername(ctx, "Mock")
s.Require().NoError(err)
s.Require().Nil(profile)
})
s.Run("an error from serializer implementation", func() {
expectedError := errors.New("mock error")
s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data")
s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError)
profile, err := s.Redis.FindProfileByUsername(ctx, "Mock")
s.Require().Nil(profile)
s.Require().ErrorIs(err, expectedError)
})
}
func (s *redisTestSuite) TestSaveProfile() {
ctx := context.Background()
s.Run("save new entity", func() {
profile := &db.Profile{
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
Username: "Mock",
}
serializedProfile := "serialized-profile"
s.Serializer.On("Serialize", profile).Return(serializedProfile, nil)
s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile)
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.SaveProfile(ctx, profile)
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Equal("mock", uuidResp)
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Equal(serializedProfile, profileResp)
})
s.Run("update exists record with changed username", func() {
newProfile := &db.Profile{
Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3",
Username: "NewMock",
}
serializedNewProfile := "serialized-new-profile"
s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil)
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile")
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.SaveProfile(ctx, newProfile)
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Equal("newmock", uuidResp)
newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock")
s.Require().Equal(serializedNewProfile, newProfileResp)
oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Empty(oldProfileResp)
})
}
func (s *redisTestSuite) TestRemoveProfileByUuid() {
ctx := context.Background()
s.Run("exists record", func() {
s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile")
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.RemoveProfileByUuid(ctx, "f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Empty(uuidResp)
profileResp := s.cmd("HGET", usernameToProfileKey, "mock")
s.Require().Empty(profileResp)
})
s.Run("uuid exists, username is missing", func() {
s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock")
err := s.Redis.RemoveProfileByUuid(ctx, "f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3")
s.Require().Empty(uuidResp)
})
s.Run("uuid not exists", func() {
err := s.Redis.RemoveProfileByUuid(ctx, "f57f36d5-4f50-4728-948a-42d5d80b18f3")
s.Require().NoError(err)
})
}
func (s *redisTestSuite) TestGetUuidForMojangUsername() {
ctx := context.Background()
s.Run("exists record", func() {
s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
uuid, username, err := s.Redis.GetUuidForMojangUsername(ctx, "Mock")
s.Require().NoError(err)
s.Require().Equal("MoCk", username)
s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid)
})
s.Run("exists record with empty uuid value", func() {
s.cmd("SET", "mojang:uuid:mock", "MoCk:")
uuid, username, err := s.Redis.GetUuidForMojangUsername(ctx, "Mock")
s.Require().NoError(err)
s.Require().Equal("MoCk", username)
s.Require().Empty(uuid)
})
s.Run("not exists record", func() {
uuid, username, err := s.Redis.GetUuidForMojangUsername(ctx, "Mock")
s.Require().NoError(err)
s.Require().Empty(username)
s.Require().Empty(uuid)
})
}
func (s *redisTestSuite) TestStoreUuid() {
ctx := context.Background()
s.Run("store uuid", func() {
err := s.Redis.StoreMojangUuid(ctx, "MoCk", "d3ca513eb3e14946b58047f2bd3530fd")
s.Require().NoError(err)
resp := s.cmd("GET", "mojang:uuid:mock")
s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd")
})
s.Run("store empty uuid", func() {
err := s.Redis.StoreMojangUuid(ctx, "MoCk", "")
s.Require().NoError(err)
resp := s.cmd("GET", "mojang:uuid:mock")
s.Require().Equal(resp, "MoCk:")
})
}
func (s *redisTestSuite) TestPing() {
err := s.Redis.Ping(context.Background())
s.Require().Nil(err)
}

136
internal/db/serializer.go Normal file
View File

@@ -0,0 +1,136 @@
package db
import (
"bytes"
"compress/zlib"
"io"
"strings"
"github.com/valyala/fastjson"
)
type ProfileSerializer interface {
Serialize(profile *Profile) ([]byte, error)
Deserialize(value []byte) (*Profile, error)
}
func NewJsonSerializer() *JsonSerializer {
return &JsonSerializer{
parserPool: &fastjson.ParserPool{},
}
}
type JsonSerializer struct {
parserPool *fastjson.ParserPool
}
// Reasons for manual JSON serialization:
// 1. The Profile must be pure and must not contain tags.
// 2. Without tags it's impossible to apply omitempty during serialization.
// 3. Without omitempty we significantly inflate the storage size, which is critical for large deployments.
// Since the JSON structure in this case is very simple, it's very easy to write a manual serialization,
// achieving all constraints above.
func (s *JsonSerializer) Serialize(profile *Profile) ([]byte, error) {
var builder strings.Builder
// Prepare for the worst case (e.g. long username, long textures links, long Mojang textures and signature)
// to prevent additional memory allocations during serialization
builder.Grow(1536)
builder.WriteString(`{"uuid":"`)
builder.WriteString(profile.Uuid)
builder.WriteString(`","username":"`)
builder.WriteString(profile.Username)
builder.WriteString(`"`)
if profile.SkinUrl != "" {
builder.WriteString(`,"skinUrl":"`)
builder.WriteString(profile.SkinUrl)
builder.WriteString(`"`)
if profile.SkinModel != "" {
builder.WriteString(`,"skinModel":"`)
builder.WriteString(profile.SkinModel)
builder.WriteString(`"`)
}
}
if profile.CapeUrl != "" {
builder.WriteString(`,"capeUrl":"`)
builder.WriteString(profile.CapeUrl)
builder.WriteString(`"`)
}
if profile.MojangTextures != "" {
builder.WriteString(`,"mojangTextures":"`)
builder.WriteString(profile.MojangTextures)
builder.WriteString(`","mojangSignature":"`)
builder.WriteString(profile.MojangSignature)
builder.WriteString(`"`)
}
builder.WriteString("}")
return []byte(builder.String()), nil
}
func (s *JsonSerializer) Deserialize(value []byte) (*Profile, error) {
parser := s.parserPool.Get()
defer s.parserPool.Put(parser)
v, err := parser.ParseBytes(value)
if err != nil {
return nil, err
}
profile := &Profile{
Uuid: string(v.GetStringBytes("uuid")),
Username: string(v.GetStringBytes("username")),
SkinUrl: string(v.GetStringBytes("skinUrl")),
SkinModel: string(v.GetStringBytes("skinModel")),
CapeUrl: string(v.GetStringBytes("capeUrl")),
MojangTextures: string(v.GetStringBytes("mojangTextures")),
MojangSignature: string(v.GetStringBytes("mojangSignature")),
}
return profile, nil
}
func NewZlibEncoder(serializer ProfileSerializer) *ZlibEncoder {
return &ZlibEncoder{serializer}
}
type ZlibEncoder struct {
serializer ProfileSerializer
}
func (s *ZlibEncoder) Serialize(profile *Profile) ([]byte, error) {
serialized, err := s.serializer.Serialize(profile)
if err != nil {
return nil, err
}
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
_, err = writer.Write(serialized)
if err != nil {
return nil, err
}
_ = writer.Close()
return buff.Bytes(), nil
}
func (s *ZlibEncoder) Deserialize(value []byte) (*Profile, error) {
buff := bytes.NewReader(value)
reader, err := zlib.NewReader(buff)
if err != nil {
return nil, err
}
resultBuffer := new(bytes.Buffer)
_, err = io.Copy(resultBuffer, reader)
if err != nil {
return nil, err
}
_ = reader.Close()
return s.serializer.Deserialize(resultBuffer.Bytes())
}

View File

@@ -0,0 +1,194 @@
package db
import (
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestJsonSerializer(t *testing.T) {
var testCases = map[string]*struct {
*Profile
Serialized []byte
Error error
}{
"full structure": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`),
},
"default skin model": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png"}`),
},
"cape only": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
CapeUrl: "https://example.com/cape.png",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","capeUrl":"https://example.com/cape.png"}`),
},
"minimal structure": {
Profile: &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
},
Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username"}`),
},
"invalid json structure": {
Serialized: []byte(`this is not json`),
Error: errors.New(`cannot parse JSON: unexpected value found: "this is not json"; unparsed tail: "this is not json"`),
},
}
serializer := NewJsonSerializer()
t.Run("Serialize", func(t *testing.T) {
for n, c := range testCases {
if c.Profile == nil {
continue
}
t.Run(n, func(t *testing.T) {
result, err := serializer.Serialize(c.Profile)
require.NoError(t, err)
require.Equal(t, c.Serialized, result)
})
}
})
t.Run("Deserialize", func(t *testing.T) {
for n, c := range testCases {
t.Run(n, func(t *testing.T) {
result, err := serializer.Deserialize(c.Serialized)
require.Equal(t, c.Error, err)
require.Equal(t, c.Profile, result)
})
}
})
}
type ProfileSerializerMock struct {
mock.Mock
}
func (m *ProfileSerializerMock) Serialize(profile *Profile) ([]byte, error) {
args := m.Called(profile)
var result []byte
if casted, ok := args.Get(0).([]byte); ok {
result = casted
}
return result, args.Error(1)
}
func (m *ProfileSerializerMock) Deserialize(value []byte) (*Profile, error) {
args := m.Called(value)
var result *Profile
if casted, ok := args.Get(0).(*Profile); ok {
result = casted
}
return result, args.Error(1)
}
func TestZlibEncoder(t *testing.T) {
profile := &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
}
t.Run("Serialize", func(t *testing.T) {
t.Run("successfully", func(t *testing.T) {
serializer := &ProfileSerializerMock{}
serializer.On("Serialize", profile).Return([]byte("serialized-string"), nil)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Serialize(profile)
require.NoError(t, err)
require.Equal(t, []byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}, result)
})
t.Run("handle error from serializer", func(t *testing.T) {
expectedError := errors.New("mock error")
serializer := &ProfileSerializerMock{}
serializer.On("Serialize", profile).Return(nil, expectedError)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Serialize(profile)
require.Same(t, expectedError, err)
require.Nil(t, result)
})
})
t.Run("Deserialize", func(t *testing.T) {
t.Run("successfully", func(t *testing.T) {
serializer := &ProfileSerializerMock{}
serializer.On("Deserialize", []byte("serialized-string")).Return(profile, nil)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
require.NoError(t, err)
require.Equal(t, profile, result)
})
t.Run("handle an error from deserializer", func(t *testing.T) {
expectedError := errors.New("mock error")
serializer := &ProfileSerializerMock{}
serializer.On("Deserialize", []byte("serialized-string")).Return(nil, expectedError)
encoder := NewZlibEncoder(serializer)
result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1})
require.Same(t, expectedError, err)
require.Nil(t, result)
})
t.Run("handle invalid zlib encoding", func(t *testing.T) {
encoder := NewZlibEncoder(&ProfileSerializerMock{})
result, err := encoder.Deserialize([]byte{0x6d, 0x6f, 0x63, 0x6b})
require.ErrorContains(t, err, "invalid")
require.Nil(t, result)
})
})
}
func BenchmarkFastJsonSerializer(b *testing.B) {
profile := &Profile{
Uuid: "f57f36d54f504728948a42d5d80b18f3",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
}
serializedProfile := []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`)
serializer := NewJsonSerializer()
b.Run("Serialize", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = serializer.Serialize(profile)
}
})
b.Run("Deserialize", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = serializer.Deserialize(serializedProfile)
}
})
}

10
internal/di/config.go Normal file
View File

@@ -0,0 +1,10 @@
package di
import (
"github.com/defval/di"
"github.com/spf13/viper"
)
var configDiOptions = di.Options(
di.Provide(viper.GetViper),
)

21
internal/di/context.go Normal file
View File

@@ -0,0 +1,21 @@
package di
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/defval/di"
)
var contextDiOptions = di.Options(
di.Provide(newBaseContext),
)
func newBaseContext() context.Context {
ctx := context.Background()
ctx, _ = signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM, os.Kill)
return ctx
}

52
internal/di/db.go Normal file
View File

@@ -0,0 +1,52 @@
package di
import (
"context"
"fmt"
"github.com/defval/di"
"github.com/etherlabsio/healthcheck/v2"
"github.com/spf13/viper"
"ely.by/chrly/internal/db"
"ely.by/chrly/internal/db/redis"
"ely.by/chrly/internal/mojang"
"ely.by/chrly/internal/profiles"
)
// Since there are no options for selecting target backends,
// all constants in this case point to static specific implementations.
var dbDiOptions = di.Options(
di.Provide(newRedis,
di.As(new(profiles.ProfilesRepository)),
di.As(new(profiles.ProfilesFinder)),
di.As(new(mojang.MojangUuidsStorage)),
),
)
func newRedis(container *di.Container, ctx context.Context, config *viper.Viper) (*redis.Redis, error) {
config.SetDefault("storage.redis.host", "localhost")
config.SetDefault("storage.redis.port", 6379)
config.SetDefault("storage.redis.poolSize", 10)
conn, err := redis.New(
ctx,
db.NewZlibEncoder(&db.JsonSerializer{}),
fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")),
config.GetInt("storage.redis.poolSize"),
)
if err != nil {
return nil, err
}
if err := container.Provide(func() *namedHealthChecker {
return &namedHealthChecker{
Name: "redis",
Checker: healthcheck.CheckerFunc(conn.Ping),
}
}); err != nil {
return nil, err
}
return conn, nil
}

17
internal/di/di.go Normal file
View File

@@ -0,0 +1,17 @@
package di
import "github.com/defval/di"
func New() (*di.Container, error) {
return di.New(
configDiOptions,
contextDiOptions,
dbDiOptions,
handlersDiOptions,
httpClientDiOptions,
loggerDiOptions,
mojangDiOptions,
profilesDiOptions,
serverDiOptions,
)
}

125
internal/di/handlers.go Normal file
View File

@@ -0,0 +1,125 @@
package di
import (
"net/http"
"slices"
"strings"
"github.com/defval/di"
"github.com/etherlabsio/healthcheck/v2"
"github.com/gorilla/mux"
"github.com/spf13/viper"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
. "ely.by/chrly/internal/http"
"ely.by/chrly/internal/security"
)
const ModuleSkinsystem = "skinsystem"
const ModuleProfiles = "profiles"
var handlersDiOptions = di.Options(
di.Provide(newHandlerFactory, di.As(new(http.Handler))),
di.Provide(newSkinsystemHandler, di.WithName(ModuleSkinsystem)),
di.Provide(newProfilesApiHandler, di.WithName(ModuleProfiles)),
)
func newHandlerFactory(
container *di.Container,
config *viper.Viper,
) (*mux.Router, error) {
enabledModules := config.GetStringSlice("modules")
// gorilla.mux has no native way to combine multiple routers.
// The hack used later in the code works for prefixes in addresses, but leads to misbehavior
// if you set an empty prefix. Since the main application should be mounted at the root prefix,
// we use it as the base router
var router *mux.Router
if slices.Contains(enabledModules, ModuleSkinsystem) {
if err := container.Resolve(&router, di.Name(ModuleSkinsystem)); err != nil {
return nil, err
}
} else {
router = mux.NewRouter()
}
router.StrictSlash(true)
router.Use(otelmux.Middleware("chrly"))
router.NotFoundHandler = http.HandlerFunc(NotFoundHandler)
if slices.Contains(enabledModules, ModuleProfiles) {
var profilesApiRouter *mux.Router
if err := container.Resolve(&profilesApiRouter, di.Name(ModuleProfiles)); err != nil {
return nil, err
}
var authenticator Authenticator
if err := container.Resolve(&authenticator); err != nil {
return nil, err
}
profilesApiRouter.Use(NewAuthenticationMiddleware(authenticator, security.ProfilesScope))
mount(router, "/api/profiles", profilesApiRouter)
}
// Resolve health checkers last, because all the services required by the application
// must first be initialized and each of them can publish its own checkers
var healthCheckers []*namedHealthChecker
if has, _ := container.Has(&healthCheckers); has {
if err := container.Resolve(&healthCheckers); err != nil {
return nil, err
}
checkersOptions := make([]healthcheck.Option, len(healthCheckers))
for i, checker := range healthCheckers {
checkersOptions[i] = healthcheck.WithChecker(checker.Name, checker.Checker)
}
router.Handle("/healthcheck", healthcheck.Handler(checkersOptions...)).Methods("GET")
}
return router, nil
}
func newSkinsystemHandler(
config *viper.Viper,
profilesProvider ProfilesProvider,
) (*mux.Router, error) {
config.SetDefault("textures.extra_param_name", "chrly")
config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?")
skinsystem, err := NewSkinsystemApi(
profilesProvider,
config.GetString("textures.extra_param_name"),
config.GetString("textures.extra_param_value"),
)
if err != nil {
return nil, err
}
return skinsystem.Handler(), nil
}
func newProfilesApiHandler(profilesManager ProfilesManager) (*mux.Router, error) {
profilesApi, err := NewProfilesApi(profilesManager)
if err != nil {
return nil, err
}
return profilesApi.Handler(), nil
}
func mount(router *mux.Router, path string, handler http.Handler) {
router.PathPrefix(path).Handler(
http.StripPrefix(
strings.TrimSuffix(path, "/"),
handler,
),
)
}
type namedHealthChecker struct {
Name string
Checker healthcheck.Checker
}

15
internal/di/httpClient.go Normal file
View File

@@ -0,0 +1,15 @@
package di
import (
"net/http"
"github.com/defval/di"
)
var httpClientDiOptions = di.Options(
di.Provide(newHttpClient),
)
func newHttpClient() *http.Client {
return &http.Client{}
}

33
internal/di/logger.go Normal file
View File

@@ -0,0 +1,33 @@
package di
import (
"github.com/defval/di"
"github.com/getsentry/raven-go"
"github.com/spf13/viper"
"ely.by/chrly/internal/version"
)
var loggerDiOptions = di.Options(
di.Provide(newSentry),
)
func newSentry(config *viper.Viper) (*raven.Client, error) {
sentryAddr := config.GetString("sentry.dsn")
if sentryAddr == "" {
return nil, nil
}
ravenClient, err := raven.New(sentryAddr)
if err != nil {
return nil, err
}
ravenClient.SetEnvironment("production")
ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver")
ravenClient.SetRelease(version.Version())
raven.DefaultClient = ravenClient
return ravenClient, nil
}

100
internal/di/mojang.go Normal file
View File

@@ -0,0 +1,100 @@
package di
import (
"net/http"
"net/url"
"time"
"github.com/defval/di"
"github.com/spf13/viper"
"ely.by/chrly/internal/mojang"
"ely.by/chrly/internal/profiles"
)
var mojangDiOptions = di.Options(
di.Provide(newMojangApi),
di.Provide(newMojangTexturesProviderFactory),
di.Provide(newMojangTexturesProvider),
di.Provide(newMojangTexturesUuidsProviderFactory),
di.Provide(newMojangTexturesBatchUUIDsProvider),
di.Provide(newMojangSignedTexturesProvider),
)
func newMojangApi(config *viper.Viper, httpClient *http.Client) (*mojang.MojangApi, error) {
batchUuidsUrl := config.GetString("mojang.batch_uuids_url")
if batchUuidsUrl != "" {
if _, err := url.ParseRequestURI(batchUuidsUrl); err != nil {
return nil, err
}
}
profileUrl := config.GetString("mojang.profile_url")
if profileUrl != "" {
if _, err := url.ParseRequestURI(batchUuidsUrl); err != nil {
return nil, err
}
}
return mojang.NewMojangApi(httpClient, batchUuidsUrl, profileUrl), nil
}
func newMojangTexturesProviderFactory(
container *di.Container,
config *viper.Viper,
) (profiles.MojangProfilesProvider, error) {
config.SetDefault("mojang_textures.enabled", true)
if !config.GetBool("mojang_textures.enabled") {
return &mojang.NilProvider{}, nil
}
var provider *mojang.MojangTexturesProvider
err := container.Resolve(&provider)
if err != nil {
return nil, err
}
return provider, nil
}
func newMojangTexturesProvider(
uuidsProvider mojang.UuidsProvider,
texturesProvider mojang.TexturesProvider,
) (*mojang.MojangTexturesProvider, error) {
return mojang.NewMojangTexturesProvider(
uuidsProvider,
texturesProvider,
)
}
func newMojangTexturesUuidsProviderFactory(
batchProvider *mojang.BatchUuidsProvider,
uuidsStorage mojang.MojangUuidsStorage,
) (mojang.UuidsProvider, error) {
return mojang.NewUuidsProviderWithCache(batchProvider, uuidsStorage)
}
func newMojangTexturesBatchUUIDsProvider(
mojangApi *mojang.MojangApi,
config *viper.Viper,
) (*mojang.BatchUuidsProvider, error) {
config.SetDefault("queue.loop_delay", 2*time.Second+500*time.Millisecond)
config.SetDefault("queue.batch_size", 10)
config.SetDefault("queue.strategy", "periodic")
return mojang.NewBatchUuidsProvider(
mojangApi.UsernamesToUuids,
config.GetInt("queue.batch_size"),
config.GetDuration("queue.loop_delay"),
config.GetString("queue.strategy") == "full-bus",
)
}
func newMojangSignedTexturesProvider(mojangApi *mojang.MojangApi) (mojang.TexturesProvider, error) {
provider, err := mojang.NewMojangApiTexturesProvider(mojangApi.UuidToTextures)
if err != nil {
return nil, err
}
return mojang.NewTexturesProviderWithInMemoryCache(provider)
}

27
internal/di/profiles.go Normal file
View File

@@ -0,0 +1,27 @@
package di
import (
"github.com/defval/di"
. "ely.by/chrly/internal/http"
"ely.by/chrly/internal/profiles"
)
var profilesDiOptions = di.Options(
di.Provide(newProfilesManager, di.As(new(ProfilesManager))),
di.Provide(newProfilesProvider, di.As(new(ProfilesProvider))),
)
func newProfilesManager(r profiles.ProfilesRepository) *profiles.Manager {
return profiles.NewManager(r)
}
func newProfilesProvider(
finder profiles.ProfilesFinder,
mojangProfilesProvider profiles.MojangProfilesProvider,
) (*profiles.Provider, error) {
return profiles.NewProvider(
finder,
mojangProfilesProvider,
)
}

57
internal/di/server.go Normal file
View File

@@ -0,0 +1,57 @@
package di
import (
"errors"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/defval/di"
"github.com/spf13/viper"
. "ely.by/chrly/internal/http"
"ely.by/chrly/internal/security"
)
var serverDiOptions = di.Options(
di.Provide(newAuthenticator, di.As(new(Authenticator))),
di.Provide(newServer),
)
func newAuthenticator(config *viper.Viper) (*security.Jwt, error) {
key := config.GetString("chrly.secret")
if key == "" {
return nil, errors.New("chrly.secret must be set in order to use authenticator")
}
return security.NewJwt([]byte(key)), nil
}
func newServer(Config *viper.Viper, Handler http.Handler) *http.Server {
Config.SetDefault("server.host", "")
Config.SetDefault("server.port", 80)
var handler http.Handler = http.HandlerFunc(func(request http.ResponseWriter, response *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
debug.PrintStack()
request.WriteHeader(http.StatusInternalServerError)
}
}()
Handler.ServeHTTP(request, response)
})
address := fmt.Sprintf("%s:%d", Config.GetString("server.host"), Config.GetInt("server.port"))
server := &http.Server{
Addr: address,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: handler,
}
return server
}

109
internal/http/http.go Normal file
View File

@@ -0,0 +1,109 @@
package http
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/gorilla/mux"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"ely.by/chrly/internal/security"
)
func StartServer(ctx context.Context, server *http.Server) {
srvErr := make(chan error, 1)
go func() {
slog.Info("Starting the server", slog.String("addr", server.Addr))
srvErr <- server.ListenAndServe()
close(srvErr)
}()
select {
case err := <-srvErr:
slog.Error("Error in the server", slog.Any("error", err))
case <-ctx.Done():
slog.Info("Got stop signal, starting graceful shutdown")
stopCtx, cancelFunc := context.WithTimeout(context.Background(), 3*time.Second)
defer cancelFunc()
_ = server.Shutdown(stopCtx)
slog.Info("Graceful shutdown succeed, exiting")
}
}
type Authenticator interface {
Authenticate(req *http.Request, scope security.Scope) error
}
func NewAuthenticationMiddleware(authenticator Authenticator, scope security.Scope) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
err := authenticator.Authenticate(req, scope)
if err != nil {
apiForbidden(resp, err.Error())
return
}
handler.ServeHTTP(resp, req)
})
}
}
func NewConditionalMiddleware(cond func(req *http.Request) bool, m mux.MiddlewareFunc) mux.MiddlewareFunc {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
if cond(req) {
handler = m.Middleware(handler)
}
handler.ServeHTTP(resp, req)
})
}
}
func NotFoundHandler(response http.ResponseWriter, _ *http.Request) {
data, _ := json.Marshal(map[string]string{
"status": "404",
"message": "Not Found",
})
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(http.StatusNotFound)
_, _ = response.Write(data)
}
func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) {
resp.WriteHeader(http.StatusBadRequest)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]any{
"errors": errorsPerField,
})
_, _ = resp.Write(result)
}
var internalServerError = []byte("Internal server error")
func apiServerError(resp http.ResponseWriter, req *http.Request, err error) {
span := trace.SpanFromContext(req.Context())
span.SetStatus(codes.Error, "")
span.RecordError(err)
resp.WriteHeader(http.StatusInternalServerError)
resp.Header().Set("Content-Type", "text/plain")
_, _ = resp.Write(internalServerError)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]any{
"error": reason,
})
_, _ = resp.Write(result)
}

129
internal/http/http_test.go Normal file
View File

@@ -0,0 +1,129 @@
package http
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/mock"
testify "github.com/stretchr/testify/require"
"ely.by/chrly/internal/security"
)
type authCheckerMock struct {
mock.Mock
}
func (m *authCheckerMock) Authenticate(req *http.Request, scope security.Scope) error {
return m.Called(req, scope).Error(0)
}
func TestAuthenticationMiddleware(t *testing.T) {
t.Run("pass", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req, security.Scope("mock")).Once().Return(nil)
isHandlerCalled := false
middlewareFunc := NewAuthenticationMiddleware(auth, "mock")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.True(t, isHandlerCalled, "Handler isn't called from the middleware")
auth.AssertExpectations(t)
})
t.Run("fail", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
auth := &authCheckerMock{}
auth.On("Authenticate", req, security.Scope("mock")).Once().Return(errors.New("error reason"))
isHandlerCalled := false
middlewareFunc := NewAuthenticationMiddleware(auth, "mock")
middlewareFunc.Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isHandlerCalled, "Handler shouldn't be called")
testify.Equal(t, 403, resp.Code)
body, _ := io.ReadAll(resp.Body)
testify.JSONEq(t, `{
"error": "error reason"
}`, string(body))
auth.AssertExpectations(t)
})
}
func TestConditionalMiddleware(t *testing.T) {
t.Run("true", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
isNestedMiddlewareCalled := false
isHandlerCalled := false
NewConditionalMiddleware(
func(req *http.Request) bool {
return true
},
func(handler http.Handler) http.Handler {
isNestedMiddlewareCalled = true
return handler
},
).Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.True(t, isNestedMiddlewareCalled, "Nested middleware wasn't called")
testify.True(t, isHandlerCalled, "Handler wasn't called from the middleware")
})
t.Run("false", func(t *testing.T) {
req := httptest.NewRequest("GET", "https://example.com", nil)
resp := httptest.NewRecorder()
isNestedMiddlewareCalled := false
isHandlerCalled := false
NewConditionalMiddleware(
func(req *http.Request) bool {
return false
},
func(handler http.Handler) http.Handler {
isNestedMiddlewareCalled = true
return handler
},
).Middleware(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
isHandlerCalled = true
})).ServeHTTP(resp, req)
testify.False(t, isNestedMiddlewareCalled, "Nested middleware shouldn't be called")
testify.True(t, isHandlerCalled, "Handler wasn't called from the middleware")
})
}
func TestNotFoundHandler(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "https://example.com", nil)
w := httptest.NewRecorder()
NotFoundHandler(w, req)
resp := w.Result()
assert.Equal(404, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := io.ReadAll(resp.Body)
assert.JSONEq(`{
"status": "404",
"message": "Not Found"
}`, string(response))
}

124
internal/http/profiles.go Normal file
View File

@@ -0,0 +1,124 @@
package http
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/huandu/xstrings"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"ely.by/chrly/internal/db"
"ely.by/chrly/internal/otel"
"ely.by/chrly/internal/profiles"
)
type ProfilesManager interface {
PersistProfile(ctx context.Context, profile *db.Profile) error
RemoveProfileByUuid(ctx context.Context, uuid string) error
}
func NewProfilesApi(profilesManager ProfilesManager) (*ProfilesApi, error) {
metrics, err := newProfilesApiMetrics(otel.GetMeter())
if err != nil {
return nil, err
}
return &ProfilesApi{
ProfilesManager: profilesManager,
metrics: metrics,
}, nil
}
type ProfilesApi struct {
ProfilesManager
metrics *profilesApiMetrics
}
func (p *ProfilesApi) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/", p.postProfileHandler).Methods(http.MethodPost)
router.HandleFunc("/{uuid}", p.deleteProfileByUuidHandler).Methods(http.MethodDelete)
return router
}
func (p *ProfilesApi) postProfileHandler(resp http.ResponseWriter, req *http.Request) {
p.metrics.UploadProfileRequest.Add(req.Context(), 1)
err := req.ParseForm()
if err != nil {
apiBadRequest(resp, map[string][]string{
"body": {"The body of the request must be a valid url-encoded string"},
})
return
}
profile := &db.Profile{
Uuid: req.Form.Get("uuid"),
Username: req.Form.Get("username"),
SkinUrl: req.Form.Get("skinUrl"),
SkinModel: req.Form.Get("skinModel"),
CapeUrl: req.Form.Get("capeUrl"),
MojangTextures: req.Form.Get("mojangTextures"),
MojangSignature: req.Form.Get("mojangSignature"),
}
err = p.PersistProfile(req.Context(), profile)
if err != nil {
var v *profiles.ValidationError
if errors.As(err, &v) {
// Manager returns ValidationError according to the struct fields names.
// They are uppercased, but otherwise the same as the names in the API.
// So to make them consistent it's enough just to make the first lowercased.
newErrors := make(map[string][]string, len(v.Errors))
for field, errors := range v.Errors {
newErrors[xstrings.FirstRuneToLower(field)] = errors
}
apiBadRequest(resp, newErrors)
return
}
apiServerError(resp, req, fmt.Errorf("unable to save profile to db: %w", err))
return
}
resp.WriteHeader(http.StatusCreated)
}
func (p *ProfilesApi) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) {
p.metrics.DeleteProfileRequest.Add(req.Context(), 1)
uuid := mux.Vars(req)["uuid"]
err := p.ProfilesManager.RemoveProfileByUuid(req.Context(), uuid)
if err != nil {
apiServerError(resp, req, fmt.Errorf("unable to delete profile from db: %w", err))
return
}
resp.WriteHeader(http.StatusNoContent)
}
func newProfilesApiMetrics(meter metric.Meter) (*profilesApiMetrics, error) {
m := &profilesApiMetrics{}
var errors, err error
m.UploadProfileRequest, err = meter.Int64Counter("chrly.app.profiles.upload.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
m.DeleteProfileRequest, err = meter.Int64Counter("chrly.app.profiles.delete.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
return m, errors
}
type profilesApiMetrics struct {
UploadProfileRequest metric.Int64Counter
DeleteProfileRequest metric.Int64Counter
}

View File

@@ -0,0 +1,171 @@
package http
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"ely.by/chrly/internal/db"
"ely.by/chrly/internal/profiles"
)
type ProfilesManagerMock struct {
mock.Mock
}
func (m *ProfilesManagerMock) PersistProfile(ctx context.Context, profile *db.Profile) error {
return m.Called(ctx, profile).Error(0)
}
func (m *ProfilesManagerMock) RemoveProfileByUuid(ctx context.Context, uuid string) error {
return m.Called(ctx, uuid).Error(0)
}
type ProfilesTestSuite struct {
suite.Suite
App *ProfilesApi
ProfilesManager *ProfilesManagerMock
}
func (t *ProfilesTestSuite) SetupSubTest() {
t.ProfilesManager = &ProfilesManagerMock{}
t.App, _ = NewProfilesApi(t.ProfilesManager)
}
func (t *ProfilesTestSuite) TearDownSubTest() {
t.ProfilesManager.AssertExpectations(t.T())
}
func (t *ProfilesTestSuite) TestPostProfile() {
t.Run("successfully post profile", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything, &db.Profile{
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
Username: "mock_username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "bW9jawo=",
MojangSignature: "bW9jawo=",
}).Once().Return(nil)
req := httptest.NewRequest("POST", "http://chrly/", bytes.NewBufferString(url.Values{
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"username": {"mock_username"},
"skinUrl": {"https://example.com/skin.png"},
"skinModel": {"slim"},
"capeUrl": {"https://example.com/cape.png"},
"mojangTextures": {"bW9jawo="},
"mojangSignature": {"bW9jawo="},
}.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusCreated, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.Empty(body)
})
t.Run("handle malformed body", func() {
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader("invalid;=url?encoded_string"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusBadRequest, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"errors": {
"body": [
"The body of the request must be a valid url-encoded string"
]
}
}`, string(body))
})
t.Run("receive validation errors", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything, mock.Anything).Once().Return(&profiles.ValidationError{
Errors: map[string][]string{
"Username": {"error1", "error2"},
},
})
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader(""))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusBadRequest, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"errors": {
"username": [
"error1",
"error2"
]
}
}`, string(body))
})
t.Run("receive other error", func() {
t.ProfilesManager.On("PersistProfile", mock.Anything, mock.Anything).Once().Return(errors.New("mock error"))
req := httptest.NewRequest("POST", "http://chrly/", strings.NewReader(""))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
func (t *ProfilesTestSuite) TestDeleteProfileByUuid() {
t.Run("successfully delete", func() {
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything, "0f657aa8-bfbe-415d-b700-5750090d3af3").Once().Return(nil)
req := httptest.NewRequest("DELETE", "http://chrly/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
resp := w.Result()
t.Equal(http.StatusNoContent, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
t.Empty(body)
})
t.Run("error from manager", func() {
t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything, mock.Anything).Return(errors.New("mock error"))
req := httptest.NewRequest("DELETE", "http://chrly/0f657aa8-bfbe-415d-b700-5750090d3af3", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
resp := w.Result()
t.Equal(http.StatusInternalServerError, resp.StatusCode)
})
}
func TestProfilesApi(t *testing.T) {
suite.Run(t, new(ProfilesTestSuite))
}

263
internal/http/skinsystem.go Normal file
View File

@@ -0,0 +1,263 @@
package http
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/gorilla/mux"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"ely.by/chrly/internal/db"
"ely.by/chrly/internal/mojang"
"ely.by/chrly/internal/otel"
)
type ProfilesProvider interface {
FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error)
}
func NewSkinsystemApi(
profilesProvider ProfilesProvider,
texturesExtraParamName string,
texturesExtraParamValue string,
) (*Skinsystem, error) {
metrics, err := newSkinsystemMetrics(otel.GetMeter())
if err != nil {
return nil, err
}
return &Skinsystem{
ProfilesProvider: profilesProvider,
TexturesExtraParamName: texturesExtraParamName,
TexturesExtraParamValue: texturesExtraParamValue,
metrics: metrics,
}, nil
}
type Skinsystem struct {
ProfilesProvider
TexturesExtraParamName string
TexturesExtraParamValue string
metrics *skinsystemApiMetrics
}
func (s *Skinsystem) Handler() *mux.Router {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", s.skinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks/{username}", s.capeHandler).Methods(http.MethodGet)
// TODO: alias /capes/{username}?
router.HandleFunc("/textures/{username}", s.texturesHandler).Methods(http.MethodGet)
router.HandleFunc("/textures/signed/{username}", s.signedTexturesHandler).Methods(http.MethodGet)
// Legacy
router.HandleFunc("/skins", s.legacySkinHandler).Methods(http.MethodGet)
router.HandleFunc("/cloaks", s.legacyCapeHandler).Methods(http.MethodGet)
return router
}
func (s *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) {
s.metrics.SkinRequest.Add(request.Context(), 1)
s.skinHandlerWithUsername(response, request, mux.Vars(request)["username"])
}
func (s *Skinsystem) legacySkinHandler(response http.ResponseWriter, request *http.Request) {
s.metrics.LegacySkinRequest.Add(request.Context(), 1)
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
s.skinHandlerWithUsername(response, request, username)
}
func (s *Skinsystem) skinHandlerWithUsername(resp http.ResponseWriter, req *http.Request, username string) {
profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), parseUsername(username), true)
if err != nil {
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
return
}
if profile == nil || profile.SkinUrl == "" {
resp.WriteHeader(http.StatusNotFound)
}
http.Redirect(resp, req, profile.SkinUrl, http.StatusMovedPermanently)
}
func (s *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) {
s.metrics.CapeRequest.Add(request.Context(), 1)
s.capeHandlerWithUsername(response, request, mux.Vars(request)["username"])
}
func (s *Skinsystem) legacyCapeHandler(response http.ResponseWriter, request *http.Request) {
s.metrics.CapeRequest.Add(request.Context(), 1)
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
s.capeHandlerWithUsername(response, request, username)
}
func (s *Skinsystem) capeHandlerWithUsername(resp http.ResponseWriter, req *http.Request, username string) {
profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), parseUsername(username), true)
if err != nil {
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
return
}
if profile == nil || profile.CapeUrl == "" {
resp.WriteHeader(http.StatusNotFound)
}
http.Redirect(resp, req, profile.CapeUrl, http.StatusMovedPermanently)
}
func (s *Skinsystem) texturesHandler(resp http.ResponseWriter, req *http.Request) {
s.metrics.TexturesRequest.Add(req.Context(), 1)
profile, err := s.ProfilesProvider.FindProfileByUsername(req.Context(), mux.Vars(req)["username"], true)
if err != nil {
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
return
}
if profile == nil {
resp.WriteHeader(http.StatusNotFound)
return
}
if profile.SkinUrl == "" && profile.CapeUrl == "" {
resp.WriteHeader(http.StatusNoContent)
return
}
textures := texturesFromProfile(profile)
responseData, _ := json.Marshal(textures)
resp.Header().Set("Content-Type", "application/json")
_, _ = resp.Write(responseData)
}
func (s *Skinsystem) signedTexturesHandler(resp http.ResponseWriter, req *http.Request) {
s.metrics.SignedTexturesRequest.Add(req.Context(), 1)
profile, err := s.ProfilesProvider.FindProfileByUsername(
req.Context(),
mux.Vars(req)["username"],
getToBool(req.URL.Query().Get("proxy")),
)
if err != nil {
apiServerError(resp, req, fmt.Errorf("unable to retrieve a profile: %w", err))
return
}
if profile == nil {
resp.WriteHeader(http.StatusNotFound)
return
}
if profile.MojangTextures == "" {
resp.WriteHeader(http.StatusNoContent)
return
}
profileResponse := &mojang.ProfileResponse{
Id: profile.Uuid,
Name: profile.Username,
Props: []*mojang.Property{
{
Name: "textures",
Signature: profile.MojangSignature,
Value: profile.MojangTextures,
},
{
Name: s.TexturesExtraParamName,
Value: s.TexturesExtraParamValue,
},
},
}
responseJson, _ := json.Marshal(profileResponse)
resp.Header().Set("Content-Type", "application/json")
_, _ = resp.Write(responseJson)
}
func parseUsername(username string) string {
return strings.TrimSuffix(username, ".png")
}
func getToBool(v string) bool {
return v == "1" || v == "true" || v == "yes"
}
func texturesFromProfile(profile *db.Profile) *mojang.TexturesResponse {
var skin *mojang.SkinTexturesResponse
if profile.SkinUrl != "" {
skin = &mojang.SkinTexturesResponse{
Url: profile.SkinUrl,
}
if profile.SkinModel != "" {
skin.Metadata = &mojang.SkinTexturesMetadata{
Model: profile.SkinModel,
}
}
}
var cape *mojang.CapeTexturesResponse
if profile.CapeUrl != "" {
cape = &mojang.CapeTexturesResponse{
Url: profile.CapeUrl,
}
}
return &mojang.TexturesResponse{
Skin: skin,
Cape: cape,
}
}
func newSkinsystemMetrics(meter metric.Meter) (*skinsystemApiMetrics, error) {
m := &skinsystemApiMetrics{}
var errors, err error
m.SkinRequest, err = meter.Int64Counter("chrly.app.skinsystem.skin.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
m.LegacySkinRequest, err = meter.Int64Counter("chrly.app.skinsystem.legacy_skin.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
m.CapeRequest, err = meter.Int64Counter("chrly.app.skinsystem.cape.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
m.LegacyCapeRequest, err = meter.Int64Counter("chrly.app.skinsystem.legacy_cape.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
m.TexturesRequest, err = meter.Int64Counter("chrly.app.skinsystem.textures.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
m.SignedTexturesRequest, err = meter.Int64Counter("chrly.app.skinsystem.signed_textures.request", metric.WithUnit("{request}"))
errors = multierr.Append(errors, err)
return m, errors
}
type skinsystemApiMetrics struct {
SkinRequest metric.Int64Counter
LegacySkinRequest metric.Int64Counter
CapeRequest metric.Int64Counter
LegacyCapeRequest metric.Int64Counter
TexturesRequest metric.Int64Counter
SignedTexturesRequest metric.Int64Counter
}

View File

@@ -0,0 +1,402 @@
package http
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/mock"
testify "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"ely.by/chrly/internal/db"
)
type ProfilesProviderMock struct {
mock.Mock
}
func (m *ProfilesProviderMock) FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error) {
args := m.Called(ctx, username, allowProxy)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
type SkinsystemTestSuite struct {
suite.Suite
App *Skinsystem
ProfilesProvider *ProfilesProviderMock
}
/********************
* Setup test suite *
********************/
func (t *SkinsystemTestSuite) SetupSubTest() {
t.ProfilesProvider = &ProfilesProviderMock{}
t.App, _ = NewSkinsystemApi(
t.ProfilesProvider,
"texturesParamName",
"texturesParamValue",
)
}
func (t *SkinsystemTestSuite) TearDownSubTest() {
t.ProfilesProvider.AssertExpectations(t.T())
}
func (t *SkinsystemTestSuite) TestSkinHandler() {
for _, url := range []string{"http://chrly/skins/mock_username", "http://chrly/skins?name=mock_username"} {
t.Run("known username with a skin", func() {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
// TODO: see the TODO about context above
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
SkinUrl: "https://example.com/skin.png",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusMovedPermanently, result.StatusCode)
t.Equal("https://example.com/skin.png", result.Header.Get("Location"))
})
t.Run("known username without a skin", func() {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusNotFound, result.StatusCode)
})
t.Run("err from profiles provider", func() {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error"))
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
t.Run("username with png extension", func() {
req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
SkinUrl: "https://example.com/skin.png",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusMovedPermanently, result.StatusCode)
t.Equal("https://example.com/skin.png", result.Header.Get("Location"))
})
t.Run("no name param", func() {
req := httptest.NewRequest("GET", "http://chrly/skins", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
resp := w.Result()
t.Equal(http.StatusBadRequest, resp.StatusCode)
})
}
func (t *SkinsystemTestSuite) TestCapeHandler() {
for _, url := range []string{"http://chrly/cloaks/mock_username", "http://chrly/cloaks?name=mock_username"} {
t.Run("known username with a skin", func() {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
// TODO: I can't find a way to verify that it's the context from the request that was passed in,
// as the Mux calls WithValue() on it, which creates a new Context and I haven't been able
// to find a way to verify that the passed context matches the base
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
CapeUrl: "https://example.com/cape.png",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusMovedPermanently, result.StatusCode)
t.Equal("https://example.com/cape.png", result.Header.Get("Location"))
})
t.Run("known username without a skin", func() {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusNotFound, result.StatusCode)
})
t.Run("err from profiles provider", func() {
req := httptest.NewRequest("GET", url, nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error"))
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
t.Run("username with png extension", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
CapeUrl: "https://example.com/cape.png",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusMovedPermanently, result.StatusCode)
t.Equal("https://example.com/cape.png", result.Header.Get("Location"))
})
t.Run("no name param", func() {
req := httptest.NewRequest("GET", "http://chrly/cloaks", nil)
w := httptest.NewRecorder()
t.App.Handler().ServeHTTP(w, req)
resp := w.Result()
t.Equal(http.StatusBadRequest, resp.StatusCode)
})
}
func (t *SkinsystemTestSuite) TestTexturesHandler() {
t.Run("known username with both textures", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
// TODO: see the TODO about context above
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
SkinUrl: "https://example.com/skin.png",
CapeUrl: "https://example.com/cape.png",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/json", result.Header.Get("Content-Type"))
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"SKIN": {
"url": "https://example.com/skin.png"
},
"CAPE": {
"url": "https://example.com/cape.png"
}
}`, string(body))
})
t.Run("known username with only slim skin", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"SKIN": {
"url": "https://example.com/skin.png",
"metadata": {
"model": "slim"
}
}
}`, string(body))
})
t.Run("known username with only cape", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{
CapeUrl: "https://example.com/cape.png",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"CAPE": {
"url": "https://example.com/cape.png"
}
}`, string(body))
})
t.Run("known username without any textures", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(&db.Profile{}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusNoContent, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.Empty(body)
})
t.Run("unknown username", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusNotFound, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.Empty(body)
})
t.Run("err from profiles provider", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, errors.New("mock error"))
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
}
func (t *SkinsystemTestSuite) TestSignedTextures() {
t.Run("exists profile with mojang textures", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
w := httptest.NewRecorder()
// TODO: see the TODO about context above
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(&db.Profile{
Uuid: "mock-uuid",
Username: "mock",
MojangTextures: "mock-mojang-textures",
MojangSignature: "mock-mojang-signature",
}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusOK, result.StatusCode)
t.Equal("application/json", result.Header.Get("Content-Type"))
body, _ := io.ReadAll(result.Body)
t.JSONEq(`{
"id": "mock-uuid",
"name": "mock",
"properties": [
{
"name": "textures",
"signature": "mock-mojang-signature",
"value": "mock-mojang-textures"
},
{
"name": "texturesParamName",
"value": "texturesParamValue"
}
]
}`, string(body))
})
t.Run("exists profile without mojang textures", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(&db.Profile{}, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusNoContent, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.Empty(body)
})
t.Run("not exists profile", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(nil, nil)
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusNotFound, result.StatusCode)
body, _ := io.ReadAll(result.Body)
t.Empty(body)
})
t.Run("err from profiles provider", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", false).Return(nil, errors.New("mock error"))
t.App.Handler().ServeHTTP(w, req)
result := w.Result()
t.Equal(http.StatusInternalServerError, result.StatusCode)
})
t.Run("should allow proxying when specified get param", func() {
req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username?proxy=true", nil)
w := httptest.NewRecorder()
t.ProfilesProvider.On("FindProfileByUsername", mock.Anything, "mock_username", true).Return(nil, nil)
t.App.Handler().ServeHTTP(w, req)
})
}
func TestSkinsystem(t *testing.T) {
suite.Run(t, new(SkinsystemTestSuite))
}
func TestParseUsername(t *testing.T) {
assert := testify.New(t)
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
}

View File

@@ -0,0 +1,208 @@
package mojang
import (
"context"
"strings"
"sync"
"time"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"ely.by/chrly/internal/otel"
"ely.by/chrly/internal/utils"
)
type UsernamesToUuidsEndpoint func(ctx context.Context, usernames []string) ([]*ProfileInfo, error)
type BatchUuidsProvider struct {
UsernamesToUuidsEndpoint
batch int
delay time.Duration
fireOnFull bool
queue *utils.Queue[*job]
fireChan chan any
stopChan chan any
onFirstCall sync.Once
metrics *batchUuidsProviderMetrics
}
func NewBatchUuidsProvider(
endpoint UsernamesToUuidsEndpoint,
batchSize int,
awaitDelay time.Duration,
fireOnFull bool,
) (*BatchUuidsProvider, error) {
queue := utils.NewQueue[*job]()
metrics, err := newBatchUuidsProviderMetrics(otel.GetMeter(), queue)
if err != nil {
return nil, err
}
return &BatchUuidsProvider{
UsernamesToUuidsEndpoint: endpoint,
stopChan: make(chan any),
batch: batchSize,
delay: awaitDelay,
fireOnFull: fireOnFull,
queue: queue,
fireChan: make(chan any),
metrics: metrics,
}, nil
}
type job struct {
Username string
Ctx context.Context
QueuingTime time.Time
ResultChan chan<- *jobResult
}
type jobResult struct {
Profile *ProfileInfo
Error error
}
func (p *BatchUuidsProvider) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
resultChan := make(chan *jobResult)
n := p.queue.Enqueue(&job{username, ctx, time.Now(), resultChan})
if p.fireOnFull && n%p.batch == 0 {
p.fireChan <- struct{}{}
}
p.onFirstCall.Do(p.startQueue)
select {
case <-ctx.Done():
return nil, ctx.Err()
case result := <-resultChan:
return result.Profile, result.Error
}
}
func (p *BatchUuidsProvider) StopQueue() {
close(p.stopChan)
}
func (p *BatchUuidsProvider) startQueue() {
go func() {
for {
t := time.NewTimer(p.delay)
select {
case <-p.stopChan:
return
case <-t.C:
go p.fireRequest()
case <-p.fireChan:
t.Stop()
go p.fireRequest()
}
}
}()
}
func (p *BatchUuidsProvider) fireRequest() {
// Since this method is an aggregator, it uses its own context to manage its lifetime
reqCtx := context.Background()
jobs := make([]*job, 0, p.batch)
n := p.batch
for {
foundJobs, left := p.queue.Dequeue(n)
for i := range foundJobs {
p.metrics.QueueTime.Record(reqCtx, float64(time.Since(foundJobs[i].QueuingTime).Milliseconds()))
if foundJobs[i].Ctx.Err() != nil {
// If the job context has already ended, its result will be returned in the GetUuid method
close(foundJobs[i].ResultChan)
foundJobs[i] = foundJobs[len(foundJobs)-1]
foundJobs = foundJobs[:len(foundJobs)-1]
}
}
jobs = append(jobs, foundJobs...)
if len(jobs) != p.batch && left != 0 {
n = p.batch - len(jobs)
continue
}
break
}
if len(jobs) == 0 {
return
}
usernames := make([]string, len(jobs))
for i, job := range jobs {
usernames[i] = job.Username
}
p.metrics.Requests.Add(reqCtx, 1)
p.metrics.BatchSize.Record(reqCtx, int64(len(usernames)))
profiles, err := p.UsernamesToUuidsEndpoint(reqCtx, usernames)
for _, job := range jobs {
response := &jobResult{}
if err == nil {
// The profiles in the response aren't ordered, so we must search each username over full array
for _, profile := range profiles {
if strings.EqualFold(job.Username, profile.Name) {
response.Profile = profile
break
}
}
} else {
response.Error = err
}
job.ResultChan <- response
close(job.ResultChan)
}
}
func newBatchUuidsProviderMetrics(meter metric.Meter, queue *utils.Queue[*job]) (*batchUuidsProviderMetrics, error) {
m := &batchUuidsProviderMetrics{}
var errors, err error
m.Requests, err = meter.Int64Counter(
"chrly.mojang.uuids.batch.request.sent",
metric.WithDescription("Number of UUIDs requests sent to Mojang API"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.BatchSize, err = meter.Int64Histogram(
"chrly.mojang.uuids.batch.request.batch_size",
metric.WithDescription("The number of usernames in the query"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.QueueLength, err = meter.Int64ObservableGauge(
"chrly.mojang.uuids.batch.queue.length",
metric.WithDescription("Number of tasks in the queue waiting for execution"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(int64(queue.Len()))
return nil
}),
)
errors = multierr.Append(errors, err)
m.QueueTime, err = meter.Float64Histogram(
"chrly.mojang.uuids.batch.queue.lag",
metric.WithDescription("Lag between placing a job in the queue and starting its processing"),
metric.WithUnit("ms"),
)
errors = multierr.Append(errors, err)
return m, errors
}
type batchUuidsProviderMetrics struct {
Requests metric.Int64Counter
BatchSize metric.Int64Histogram
QueueLength metric.Int64ObservableGauge
QueueTime metric.Float64Histogram
}

View File

@@ -0,0 +1,210 @@
package mojang
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
var awaitDelay = 20 * time.Millisecond
type mojangUsernamesToUuidsRequestMock struct {
mock.Mock
}
func (o *mojangUsernamesToUuidsRequestMock) UsernamesToUuids(ctx context.Context, usernames []string) ([]*ProfileInfo, error) {
args := o.Called(ctx, usernames)
var result []*ProfileInfo
if casted, ok := args.Get(0).([]*ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type batchUuidsProviderGetUuidResult struct {
Result *ProfileInfo
Error error
}
type batchUuidsProviderTestSuite struct {
suite.Suite
Provider *BatchUuidsProvider
MojangApi *mojangUsernamesToUuidsRequestMock
}
func (s *batchUuidsProviderTestSuite) SetupTest() {
s.MojangApi = &mojangUsernamesToUuidsRequestMock{}
s.Provider, _ = NewBatchUuidsProvider(
s.MojangApi.UsernamesToUuids,
3,
awaitDelay,
false,
)
}
func (s *batchUuidsProviderTestSuite) TearDownTest() {
s.MojangApi.AssertExpectations(s.T())
s.Provider.StopQueue()
}
func (s *batchUuidsProviderTestSuite) GetUuidAsync(username string) <-chan *batchUuidsProviderGetUuidResult {
return s.GetUuidAsyncWithCtx(context.Background(), username)
}
func (s *batchUuidsProviderTestSuite) GetUuidAsyncWithCtx(ctx context.Context, username string) <-chan *batchUuidsProviderGetUuidResult {
startedChan := make(chan any)
c := make(chan *batchUuidsProviderGetUuidResult, 1)
go func() {
close(startedChan)
profile, err := s.Provider.GetUuid(ctx, username)
c <- &batchUuidsProviderGetUuidResult{
Result: profile,
Error: err,
}
close(c)
}()
<-startedChan
return c
}
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesSuccessfully() {
expectedUsernames := []string{"username1", "username2"}
expectedResult1 := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username1"}
expectedResult2 := &ProfileInfo{Id: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Name: "username2"}
s.MojangApi.On("UsernamesToUuids", mock.Anything, expectedUsernames).Once().Return([]*ProfileInfo{
expectedResult1,
expectedResult2,
}, nil)
chan1 := s.GetUuidAsync("username1")
chan2 := s.GetUuidAsync("username2")
s.Require().Empty(chan1)
s.Require().Empty(chan2)
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
result1 := <-chan1
result2 := <-chan2
s.Require().NoError(result1.Error)
s.Require().Equal(expectedResult1, result1.Result)
s.Require().NoError(result2.Error)
s.Require().Equal(expectedResult2, result2.Result)
// Await a few more iterations to ensure, that no requests will be performed when there are no additional tasks
time.Sleep(awaitDelay * 3)
}
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesSplitByMultipleIterations() {
var emptyResponse []string
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username4"}).Once().Return(emptyResponse, nil)
resultChan1 := s.GetUuidAsync("username1")
resultChan2 := s.GetUuidAsync("username2")
resultChan3 := s.GetUuidAsync("username3")
resultChan4 := s.GetUuidAsync("username4")
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
s.Require().NotEmpty(resultChan1)
s.Require().NotEmpty(resultChan2)
s.Require().NotEmpty(resultChan3)
s.Require().Empty(resultChan4)
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
s.Require().NotEmpty(resultChan4)
}
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesWhenOneOfContextIsDeadlined() {
var emptyResponse []string
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username1", "username2", "username4"}).Once().Return(emptyResponse, nil)
ctx, cancelCtx := context.WithCancel(context.Background())
resultChan1 := s.GetUuidAsync("username1")
resultChan2 := s.GetUuidAsync("username2")
resultChan3 := s.GetUuidAsyncWithCtx(ctx, "username3")
resultChan4 := s.GetUuidAsync("username4")
cancelCtx()
time.Sleep(time.Duration(float64(awaitDelay) * 0.5))
s.Empty(resultChan1)
s.Empty(resultChan2)
s.NotEmpty(resultChan3, "canceled context must immediately release the job")
s.Empty(resultChan4)
result3 := <-resultChan3
s.Nil(result3.Result)
s.ErrorIs(result3.Error, context.Canceled)
time.Sleep(awaitDelay)
s.Require().NotEmpty(resultChan1)
s.Require().NotEmpty(resultChan2)
s.Require().NotEmpty(resultChan4)
}
func (s *batchUuidsProviderTestSuite) TestGetUuidForManyUsernamesFireOnFull() {
s.Provider.fireOnFull = true
var emptyResponse []string
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username1", "username2", "username3"}).Once().Return(emptyResponse, nil)
s.MojangApi.On("UsernamesToUuids", mock.Anything, []string{"username4"}).Once().Return(emptyResponse, nil)
resultChan1 := s.GetUuidAsync("username1")
resultChan2 := s.GetUuidAsync("username2")
resultChan3 := s.GetUuidAsync("username3")
resultChan4 := s.GetUuidAsync("username4")
time.Sleep(time.Duration(float64(awaitDelay) * 0.5))
s.Require().NotEmpty(resultChan1)
s.Require().NotEmpty(resultChan2)
s.Require().NotEmpty(resultChan3)
s.Require().Empty(resultChan4)
time.Sleep(time.Duration(float64(awaitDelay) * 1.5))
s.Require().NotEmpty(resultChan4)
}
func (s *batchUuidsProviderTestSuite) TestGetUuidForFewUsernamesWithAnError() {
expectedUsernames := []string{"username1", "username2"}
expectedError := errors.New("mock error")
s.MojangApi.On("UsernamesToUuids", mock.Anything, expectedUsernames).Once().Return(nil, expectedError)
resultChan1 := s.GetUuidAsync("username1")
resultChan2 := s.GetUuidAsync("username2")
result1 := <-resultChan1
s.Assert().Nil(result1.Result)
s.Assert().Equal(expectedError, result1.Error)
result2 := <-resultChan2
s.Assert().Nil(result2.Result)
s.Assert().Equal(expectedError, result2.Error)
}
func TestBatchUuidsProvider(t *testing.T) {
suite.Run(t, new(batchUuidsProviderTestSuite))
}

266
internal/mojang/client.go Normal file
View File

@@ -0,0 +1,266 @@
package mojang
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
)
type MojangApi struct {
http *http.Client
batchUuidsUrl string
profileUrl string
}
func NewMojangApi(
http *http.Client,
batchUuidsUrl string,
profileUrl string,
) *MojangApi {
if batchUuidsUrl == "" {
batchUuidsUrl = "https://api.mojang.com/profiles/minecraft"
}
if profileUrl == "" {
profileUrl = "https://sessionserver.mojang.com/session/minecraft/profile/"
}
if !strings.HasSuffix(profileUrl, "/") {
profileUrl += "/"
}
return &MojangApi{
http,
batchUuidsUrl,
profileUrl,
}
}
// Exchanges usernames array to array of uuids
// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs
func (c *MojangApi) UsernamesToUuids(ctx context.Context, usernames []string) ([]*ProfileInfo, error) {
requestBody, _ := json.Marshal(usernames)
request, err := http.NewRequestWithContext(ctx, "POST", c.batchUuidsUrl, bytes.NewBuffer(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
response, err := c.http.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode != 200 {
return nil, errorFromResponse(response)
}
var result []*ProfileInfo
body, _ := io.ReadAll(response.Body)
err = json.Unmarshal(body, &result)
if err != nil {
return nil, err
}
return result, nil
}
// Obtains textures information for provided uuid
// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
func (c *MojangApi) UuidToTextures(ctx context.Context, uuid string, signed bool) (*ProfileResponse, error) {
normalizedUuid := strings.ReplaceAll(uuid, "-", "")
url := c.profileUrl + normalizedUuid
if signed {
url += "?unsigned=false"
}
request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
response, err := c.http.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode == 204 {
return nil, nil
}
if response.StatusCode != 200 {
return nil, errorFromResponse(response)
}
var result *ProfileResponse
body, _ := io.ReadAll(response.Body)
err = json.Unmarshal(body, &result)
if err != nil {
return nil, err
}
return result, nil
}
type ProfileResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Props []*Property `json:"properties"`
once sync.Once
decodedTextures *TexturesProp
decodedErr error
}
type TexturesProp struct {
Timestamp int64 `json:"timestamp"`
ProfileID string `json:"profileId"`
ProfileName string `json:"profileName"`
Textures *TexturesResponse `json:"textures"`
}
type TexturesResponse struct {
Skin *SkinTexturesResponse `json:"SKIN,omitempty"`
Cape *CapeTexturesResponse `json:"CAPE,omitempty"`
}
type SkinTexturesResponse struct {
Url string `json:"url"`
Metadata *SkinTexturesMetadata `json:"metadata,omitempty"`
}
type SkinTexturesMetadata struct {
Model string `json:"model"`
}
type CapeTexturesResponse struct {
Url string `json:"url"`
}
func (t *ProfileResponse) DecodeTextures() (*TexturesProp, error) {
t.once.Do(func() {
var texturesProp string
for _, prop := range t.Props {
if prop.Name == "textures" {
texturesProp = prop.Value
break
}
}
if texturesProp == "" {
return
}
decodedTextures, err := DecodeTextures(texturesProp)
if err != nil {
t.decodedErr = err
} else {
t.decodedTextures = decodedTextures
}
})
return t.decodedTextures, t.decodedErr
}
type Property struct {
Name string `json:"name"`
Signature string `json:"signature,omitempty"`
Value string `json:"value"`
}
type ProfileInfo struct {
Id string `json:"id"`
Name string `json:"name"`
IsLegacy bool `json:"legacy,omitempty"`
IsDemo bool `json:"demo,omitempty"`
}
func errorFromResponse(response *http.Response) error {
switch {
case response.StatusCode == 400:
type errorResponse struct {
Error string `json:"error"`
Message string `json:"errorMessage"`
}
var decodedError *errorResponse
body, _ := io.ReadAll(response.Body)
_ = json.Unmarshal(body, &decodedError)
return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message}
case response.StatusCode == 403:
return &ForbiddenError{}
case response.StatusCode == 429:
return &TooManyRequestsError{}
case response.StatusCode >= 500:
return &ServerError{Status: response.StatusCode}
}
return fmt.Errorf("unexpected response status code: %d", response.StatusCode)
}
// When passed request params are invalid, Mojang returns 400 Bad Request error
type BadRequestError struct {
ErrorType string
Message string
}
func (e *BadRequestError) Error() string {
return fmt.Sprintf("400 %s: %s", e.ErrorType, e.Message)
}
// When Mojang decides you're such a bad guy, this error appears (even if the request has no authorization)
type ForbiddenError struct {
}
func (*ForbiddenError) Error() string {
return "403: Forbidden"
}
// When you exceed the set limit of requests, this error will be returned
type TooManyRequestsError struct {
}
func (*TooManyRequestsError) Error() string {
return "429: Too Many Requests"
}
// ServerError happens when Mojang's API returns any response with 50* status
type ServerError struct {
Status int
}
func (e *ServerError) Error() string {
return fmt.Sprintf("%d: %s", e.Status, "Server error")
}
func DecodeTextures(encodedTextures string) (*TexturesProp, error) {
jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures)
if err != nil {
return nil, err
}
var result *TexturesProp
err = json.Unmarshal(jsonStr, &result)
if err != nil {
return nil, err
}
return result, nil
}
func EncodeTextures(textures *TexturesProp) string {
jsonSerialized, _ := json.Marshal(textures)
return base64.URLEncoding.EncodeToString(jsonSerialized)
}

View File

@@ -0,0 +1,318 @@
package mojang
import (
"context"
"net/http"
"testing"
"github.com/h2non/gock"
"github.com/stretchr/testify/suite"
testify "github.com/stretchr/testify/assert"
)
type MojangApiSuite struct {
suite.Suite
api *MojangApi
}
func (s *MojangApiSuite) SetupTest() {
httpClient := &http.Client{}
gock.InterceptClient(httpClient)
s.api = NewMojangApi(httpClient, "", "")
}
func (s *MojangApiSuite) TearDownTest() {
gock.Off()
}
func (s *MojangApiSuite) TestUsernamesToUuidsSuccessfully() {
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
JSON([]string{"Thinkofdeath", "maksimkurb"}).
Reply(200).
JSON([]map[string]any{
{
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
"name": "Thinkofdeath",
"legacy": false,
"demo": true,
},
{
"id": "0d252b7218b648bfb86c2ae476954d32",
"name": "maksimkurb",
// There are no legacy or demo fields
},
})
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
s.NoError(err)
s.Len(result, 2)
s.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id)
s.Equal("Thinkofdeath", result[0].Name)
s.False(result[0].IsLegacy)
s.True(result[0].IsDemo)
s.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id)
s.Equal("maksimkurb", result[1].Name)
s.False(result[1].IsLegacy)
s.False(result[1].IsDemo)
}
func (s *MojangApiSuite) TestUsernamesToUuidsBadRequest() {
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(400).
JSON(map[string]any{
"error": "IllegalArgumentException",
"errorMessage": "profileName can not be null or empty.",
})
result, err := s.api.UsernamesToUuids(context.Background(), []string{""})
s.Nil(result)
s.IsType(&BadRequestError{}, err)
s.EqualError(err, "400 IllegalArgumentException: profileName can not be null or empty.")
}
func (s *MojangApiSuite) TestUsernamesToUuidsForbidden() {
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(403).
BodyString("just because")
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
s.Nil(result)
s.IsType(&ForbiddenError{}, err)
s.EqualError(err, "403: Forbidden")
}
func (s *MojangApiSuite) TestUsernamesToUuidsTooManyRequests() {
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(429).
JSON(map[string]any{
"error": "TooManyRequestsException",
"errorMessage": "The client has sent too many requests within a certain amount of time",
})
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
s.Nil(result)
s.IsType(&TooManyRequestsError{}, err)
s.EqualError(err, "429: Too Many Requests")
}
func (s *MojangApiSuite) TestUsernamesToUuidsServerError() {
gock.New("https://api.mojang.com").
Post("/profiles/minecraft").
Reply(500).
BodyString("500 Internal Server Error")
result, err := s.api.UsernamesToUuids(context.Background(), []string{"Thinkofdeath", "maksimkurb"})
s.Nil(result)
s.IsType(&ServerError{}, err)
s.EqualError(err, "500: Server error")
s.Equal(500, err.(*ServerError).Status)
}
func (s *MojangApiSuite) TestUuidToTexturesSuccessfulResponse() {
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(200).
JSON(map[string]any{
"id": "4566e69fc90748ee8d71d7ba5aa00d20",
"name": "Thinkofdeath",
"properties": []any{
map[string]any{
"name": "textures",
"value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=",
},
},
})
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
s.NoError(err)
s.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id)
s.Equal("Thinkofdeath", result.Name)
s.Equal(1, len(result.Props))
s.Equal("textures", result.Props[0].Name)
s.Equal(476, len(result.Props[0].Value))
s.Equal("", result.Props[0].Signature)
}
func (s *MojangApiSuite) TestUuidToTexturesEmptyResponse() {
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(204).
BodyString("")
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
s.NoError(err)
s.Nil(result)
}
func (s *MojangApiSuite) TestUuidToTexturesTooManyRequests() {
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(429).
JSON(map[string]any{
"error": "TooManyRequestsException",
"errorMessage": "The client has sent too many requests within a certain amount of time",
})
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
s.Nil(result)
s.IsType(&TooManyRequestsError{}, err)
s.EqualError(err, "429: Too Many Requests")
}
func (s *MojangApiSuite) TestUuidToTexturesServerError() {
gock.New("https://sessionserver.mojang.com").
Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20").
Reply(500).
BodyString("500 Internal Server Error")
result, err := s.api.UuidToTextures(context.Background(), "4566e69fc90748ee8d71d7ba5aa00d20", false)
s.Nil(result)
s.IsType(&ServerError{}, err)
s.EqualError(err, "500: Server error")
s.Equal(500, err.(*ServerError).Status)
}
func TestMojangApi(t *testing.T) {
suite.Run(t, new(MojangApiSuite))
}
func TestProfileResponse(t *testing.T) {
t.Run("DecodeTextures", func(t *testing.T) {
obj := &ProfileResponse{
Id: "00000000000000000000000000000000",
Name: "mock",
Props: []*Property{
{
Name: "textures",
Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
},
},
}
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID)
})
t.Run("DecodedTextures without textures prop", func(t *testing.T) {
obj := &ProfileResponse{
Id: "00000000000000000000000000000000",
Name: "mock",
Props: []*Property{},
}
textures, err := obj.DecodeTextures()
testify.Nil(t, err)
testify.Nil(t, textures)
})
}
type texturesTestCase struct {
Name string
Encoded string
Decoded *TexturesProp
}
var texturesTestCases = []*texturesTestCase{
{
Name: "property without textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319",
Decoded: &TexturesProp{
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
ProfileName: "ErickSkrauch",
Timestamp: int64(1555856010494),
Textures: &TexturesResponse{},
},
},
{
Name: "property with classic skin textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=",
Decoded: &TexturesProp{
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
ProfileName: "ErickSkrauch",
Timestamp: int64(1555856307412),
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e",
},
},
},
},
{
Name: "property with alex skin textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19",
Decoded: &TexturesProp{
ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9",
ProfileName: "ErickSkrauch",
Timestamp: int64(1555856494791),
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859",
Metadata: &SkinTexturesMetadata{
Model: "slim",
},
},
},
},
},
{
Name: "property with skin and cape textures",
Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=",
Decoded: &TexturesProp{
ProfileID: "d90b68bc81724329a047f1186dcd4336",
ProfileName: "akronman1",
Timestamp: int64(1555857675335),
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7",
},
Cape: &CapeTexturesResponse{
Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf",
},
},
},
},
}
func TestDecodeTextures(t *testing.T) {
for _, testCase := range texturesTestCases {
t.Run("decode "+testCase.Name, func(t *testing.T) {
assert := testify.New(t)
result, err := DecodeTextures(testCase.Encoded)
assert.Nil(err)
assert.Equal(testCase.Decoded, result)
})
}
t.Run("should return error if invalid base64 passed", func(t *testing.T) {
assert := testify.New(t)
result, err := DecodeTextures("invalid base64")
assert.Error(err)
assert.Nil(result)
})
t.Run("should return error if invalid json found inside base64", func(t *testing.T) {
assert := testify.New(t)
result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json"
assert.Error(err)
assert.Nil(result)
})
}
func TestEncodeTextures(t *testing.T) {
for _, testCase := range texturesTestCases {
t.Run("encode "+testCase.Name, func(t *testing.T) {
assert := testify.New(t)
result := EncodeTextures(testCase.Decoded)
assert.Equal(testCase.Encoded, result)
})
}
}

174
internal/mojang/provider.go Normal file
View File

@@ -0,0 +1,174 @@
package mojang
import (
"context"
"errors"
"regexp"
"strings"
"github.com/brunomvsouza/singleflight"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"ely.by/chrly/internal/otel"
)
const ScopeName = "ely.by/chrly/internal/mojang"
var InvalidUsername = errors.New("the username passed doesn't meet Mojang's requirements")
// https://help.minecraft.net/hc/en-us/articles/4408950195341#h_01GE5JX1Z0CZ833A7S54Y195KV
var allowedUsernamesRegex = regexp.MustCompile(`(?i)^[0-9a-z_]{3,16}$`)
type UuidsProvider interface {
GetUuid(ctx context.Context, username string) (*ProfileInfo, error)
}
type TexturesProvider interface {
GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error)
}
func NewMojangTexturesProvider(
uuidsProvider UuidsProvider,
texturesProvider TexturesProvider,
) (*MojangTexturesProvider, error) {
meter, err := newProviderMetrics(otel.GetMeter())
if err != nil {
return nil, err
}
return &MojangTexturesProvider{
UuidsProvider: uuidsProvider,
TexturesProvider: texturesProvider,
metrics: meter,
}, nil
}
type MojangTexturesProvider struct {
UuidsProvider
TexturesProvider
metrics *providerMetrics
group singleflight.Group[string, *ProfileResponse]
}
func (p *MojangTexturesProvider) GetForUsername(ctx context.Context, username string) (*ProfileResponse, error) {
if !allowedUsernamesRegex.MatchString(username) {
return nil, InvalidUsername
}
username = strings.ToLower(username)
result, err, shared := p.group.Do(username, func() (*ProfileResponse, error) {
var profile *ProfileInfo
var textures *ProfileResponse
var err error
defer p.recordMetrics(ctx, profile, textures, err)
profile, err = p.UuidsProvider.GetUuid(ctx, username)
if err != nil {
return nil, err
}
if profile == nil {
return nil, nil
}
textures, err = p.TexturesProvider.GetTextures(ctx, profile.Id)
return textures, err
})
if shared {
p.metrics.Shared.Add(ctx, 1)
}
return result, err
}
func (p *MojangTexturesProvider) recordMetrics(ctx context.Context, profile *ProfileInfo, textures *ProfileResponse, err error) {
if err != nil {
p.metrics.Failed.Add(ctx, 1)
return
}
if profile == nil {
p.metrics.UsernameMissed.Add(ctx, 1)
p.metrics.TextureMissed.Add(ctx, 1)
return
}
p.metrics.UsernameFound.Add(ctx, 1)
if textures != nil {
p.metrics.TextureFound.Add(ctx, 1)
} else {
p.metrics.TextureMissed.Add(ctx, 1)
}
}
type NilProvider struct {
}
func (*NilProvider) GetForUsername(ctx context.Context, username string) (*ProfileResponse, error) {
return nil, nil
}
func newProviderMetrics(meter metric.Meter) (*providerMetrics, error) {
m := &providerMetrics{}
var errors, err error
m.UsernameFound, err = meter.Int64Counter(
"mojang.provider.username_found",
metric.WithDescription("Number of queries for which username was found"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.UsernameMissed, err = meter.Int64Counter(
"chrly.mojang.provider.username_missed",
metric.WithDescription("Number of queries for which username was not found"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.TextureFound, err = meter.Int64Counter(
"chrly.mojang.provider.textures_found",
metric.WithDescription("Number of queries for which textures were successfully found"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.TextureMissed, err = meter.Int64Counter(
"chrly.mojang.provider.textures_missed",
metric.WithDescription("Number of queries for which no textures were found"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.Failed, err = meter.Int64Counter(
"chrly.mojang.provider.failed",
metric.WithDescription("Number of requests that ended in an error"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.Shared, err = meter.Int64Counter(
"chrly.mojang.provider.singleflight.shared",
metric.WithDescription("Number of requests that are already being processed in another thread"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
return m, errors
}
type providerMetrics struct {
UsernameFound metric.Int64Counter
UsernameMissed metric.Int64Counter
TextureFound metric.Int64Counter
TextureMissed metric.Int64Counter
Failed metric.Int64Counter
Shared metric.Int64Counter
}

View File

@@ -0,0 +1,180 @@
package mojang
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type mockUuidsProvider struct {
mock.Mock
}
func (m *mockUuidsProvider) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
args := m.Called(ctx, username)
var result *ProfileInfo
if casted, ok := args.Get(0).(*ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type TexturesProviderMock struct {
mock.Mock
}
func (m *TexturesProviderMock) GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error) {
args := m.Called(ctx, uuid)
var result *ProfileResponse
if casted, ok := args.Get(0).(*ProfileResponse); ok {
result = casted
}
return result, args.Error(1)
}
type providerTestSuite struct {
suite.Suite
Provider *MojangTexturesProvider
UuidsProvider *mockUuidsProvider
TexturesProvider *TexturesProviderMock
}
func (s *providerTestSuite) SetupTest() {
s.UuidsProvider = &mockUuidsProvider{}
s.TexturesProvider = &TexturesProviderMock{}
s.Provider, _ = NewMojangTexturesProvider(
s.UuidsProvider,
s.TexturesProvider,
)
}
func (s *providerTestSuite) TearDownTest() {
s.UuidsProvider.AssertExpectations(s.T())
s.TexturesProvider.AssertExpectations(s.T())
}
func (s *providerTestSuite) TestGetForValidUsernameSuccessfully() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
ctx := context.Background()
s.UuidsProvider.On("GetUuid", ctx, "username").Once().Return(expectedProfile, nil)
s.TexturesProvider.On("GetTextures", ctx, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
result, err := s.Provider.GetForUsername(ctx, "username")
s.NoError(err)
s.Same(expectedResult, result)
}
func (s *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() {
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(nil, nil)
result, err := s.Provider.GetForUsername(context.Background(), "username")
s.NoError(err)
s.Nil(result)
}
func (s *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoMojangSkin() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(expectedProfile, nil)
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, nil)
result, err := s.Provider.GetForUsername(context.Background(), "username")
s.NoError(err)
s.Nil(result)
}
func (s *providerTestSuite) TestGetForTheSameUsernameInRow() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
awaitChan := make(chan time.Time)
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().WaitUntil(awaitChan).Return(expectedProfile, nil)
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil)
results := make([]*ProfileResponse, 2)
var wgStarted sync.WaitGroup
var wgDone sync.WaitGroup
for i := 0; i < 2; i++ {
wgStarted.Add(1)
wgDone.Add(1)
go func(i int) {
wgStarted.Done()
textures, _ := s.Provider.GetForUsername(context.Background(), "username")
results[i] = textures
wgDone.Done()
}(i)
}
wgStarted.Wait()
close(awaitChan)
wgDone.Wait()
s.Same(expectedResult, results[0])
s.Same(expectedResult, results[1])
}
func (s *providerTestSuite) TestGetForTheSameUsernameOneAfterAnother() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Times(2).Return(expectedProfile, nil)
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Times(2).Return(expectedResult, nil)
// Just ensure that providers will be called twice
_, _ = s.Provider.GetForUsername(context.Background(), "username")
time.Sleep(time.Millisecond * 20)
_, _ = s.Provider.GetForUsername(context.Background(), "username")
}
func (s *providerTestSuite) TestGetForNotAllowedMojangUsername() {
result, err := s.Provider.GetForUsername(context.Background(), "Not allowed")
s.ErrorIs(err, InvalidUsername)
s.Nil(result)
}
func (s *providerTestSuite) TestGetErrorFromUuidsProvider() {
err := errors.New("mock error")
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(nil, err)
result, resErr := s.Provider.GetForUsername(context.Background(), "username")
s.Nil(result)
s.Equal(err, resErr)
}
func (s *providerTestSuite) TestGetErrorFromTexturesProvider() {
expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"}
err := errors.New("mock error")
s.UuidsProvider.On("GetUuid", mock.Anything, "username").Once().Return(expectedProfile, nil)
s.TexturesProvider.On("GetTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil, err)
result, resErr := s.Provider.GetForUsername(context.Background(), "username")
s.Nil(result)
s.Same(err, resErr)
}
func TestProvider(t *testing.T) {
suite.Run(t, new(providerTestSuite))
}
func TestNilProvider_GetForUsername(t *testing.T) {
provider := &NilProvider{}
result, err := provider.GetForUsername(context.Background(), "username")
require.Nil(t, result)
require.NoError(t, err)
}

View File

@@ -0,0 +1,141 @@
package mojang
import (
"context"
"sync"
"time"
"github.com/jellydator/ttlcache/v3"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"ely.by/chrly/internal/otel"
)
type MojangApiTexturesProviderFunc func(ctx context.Context, uuid string, signed bool) (*ProfileResponse, error)
func NewMojangApiTexturesProvider(endpoint MojangApiTexturesProviderFunc) (*MojangApiTexturesProvider, error) {
metrics, err := newMojangApiTexturesProviderMetrics(otel.GetMeter())
if err != nil {
return nil, err
}
return &MojangApiTexturesProvider{
MojangApiTexturesEndpoint: endpoint,
metrics: metrics,
}, nil
}
type MojangApiTexturesProvider struct {
MojangApiTexturesEndpoint MojangApiTexturesProviderFunc
metrics *mojangApiTexturesProviderMetrics
}
func (p *MojangApiTexturesProvider) GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error) {
p.metrics.Requests.Add(ctx, 1)
return p.MojangApiTexturesEndpoint(ctx, uuid, true)
}
// Perfectly there should be an object with provider and cache implementation,
// but I decided not to introduce a layer and just implement cache in place.
type TexturesProviderWithInMemoryCache struct {
provider TexturesProvider
once sync.Once
cache *ttlcache.Cache[string, *ProfileResponse]
metrics *texturesProviderWithInMemoryCacheMetrics
}
func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) (*TexturesProviderWithInMemoryCache, error) {
metrics, err := newTexturesProviderWithInMemoryCacheMetrics(otel.GetMeter())
if err != nil {
return nil, err
}
return &TexturesProviderWithInMemoryCache{
provider: provider,
cache: ttlcache.New[string, *ProfileResponse](
ttlcache.WithDisableTouchOnHit[string, *ProfileResponse](),
// I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error
),
metrics: metrics,
}, nil
}
func (s *TexturesProviderWithInMemoryCache) GetTextures(ctx context.Context, uuid string) (*ProfileResponse, error) {
item := s.cache.Get(uuid)
// Don't check item.IsExpired() since Get function is already did this check
if item != nil {
s.metrics.Hits.Add(ctx, 1)
return item.Value(), nil
}
s.metrics.Misses.Add(ctx, 1)
result, err := s.provider.GetTextures(ctx, uuid)
if err != nil {
return nil, err
}
s.cache.Set(uuid, result, time.Minute)
// Call it only after first set so GC will work more often
s.startGcOnce()
return result, nil
}
func (s *TexturesProviderWithInMemoryCache) StopGC() {
// If you call the Stop() on a non-started GC, the process will hang trying to close the uninitialized channel
s.startGcOnce()
s.cache.Stop()
}
func (s *TexturesProviderWithInMemoryCache) startGcOnce() {
s.once.Do(func() {
go s.cache.Start()
})
}
func newMojangApiTexturesProviderMetrics(meter metric.Meter) (*mojangApiTexturesProviderMetrics, error) {
m := &mojangApiTexturesProviderMetrics{}
var errors, err error
m.Requests, err = meter.Int64Counter(
"chrly.mojang.textures.request.sent",
metric.WithDescription("Number of textures requests sent to Mojang API"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
return m, errors
}
type mojangApiTexturesProviderMetrics struct {
Requests metric.Int64Counter
}
func newTexturesProviderWithInMemoryCacheMetrics(meter metric.Meter) (*texturesProviderWithInMemoryCacheMetrics, error) {
m := &texturesProviderWithInMemoryCacheMetrics{}
var errors, err error
m.Hits, err = meter.Int64Counter(
"chrly.mojang.textures.cache.hit",
metric.WithDescription("Number of Mojang textures found in the local cache"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.Misses, err = meter.Int64Counter(
"chrly.mojang.textures.cache.miss",
metric.WithDescription("Number of Mojang textures missing from local cache"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
return m, errors
}
type texturesProviderWithInMemoryCacheMetrics struct {
Hits metric.Int64Counter
Misses metric.Int64Counter
}

View File

@@ -0,0 +1,141 @@
package mojang
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
var signedTexturesResponse = &ProfileResponse{
Id: "dead24f9a4fa4877b7b04c8c6c72bb46",
Name: "mock",
Props: []*Property{
{
Name: "textures",
Value: EncodeTextures(&TexturesProp{
Timestamp: time.Now().UnixNano() / 10e5,
ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46",
ProfileName: "mock",
Textures: &TexturesResponse{
Skin: &SkinTexturesResponse{
Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75",
},
},
}),
},
},
}
type MojangUuidToTexturesRequestMock struct {
mock.Mock
}
func (m *MojangUuidToTexturesRequestMock) UuidToTextures(ctx context.Context, uuid string, signed bool) (*ProfileResponse, error) {
args := m.Called(ctx, uuid, signed)
var result *ProfileResponse
if casted, ok := args.Get(0).(*ProfileResponse); ok {
result = casted
}
return result, args.Error(1)
}
type MojangApiTexturesProviderSuite struct {
suite.Suite
Provider *MojangApiTexturesProvider
MojangApi *MojangUuidToTexturesRequestMock
}
func (s *MojangApiTexturesProviderSuite) SetupTest() {
s.MojangApi = &MojangUuidToTexturesRequestMock{}
s.Provider, _ = NewMojangApiTexturesProvider(s.MojangApi.UuidToTextures)
}
func (s *MojangApiTexturesProviderSuite) TearDownTest() {
s.MojangApi.AssertExpectations(s.T())
}
func (s *MojangApiTexturesProviderSuite) TestGetTextures() {
ctx := context.Background()
s.MojangApi.On("UuidToTextures", ctx, "dead24f9a4fa4877b7b04c8c6c72bb46", true).Once().Return(signedTexturesResponse, nil)
result, err := s.Provider.GetTextures(ctx, "dead24f9a4fa4877b7b04c8c6c72bb46")
s.Require().NoError(err)
s.Require().Equal(signedTexturesResponse, result)
}
func (s *MojangApiTexturesProviderSuite) TestGetTexturesWithError() {
expectedError := errors.New("mock error")
s.MojangApi.On("UuidToTextures", mock.Anything, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true).Once().Return(nil, expectedError)
result, err := s.Provider.GetTextures(context.Background(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
s.Require().Nil(result)
s.Require().Equal(expectedError, err)
}
func TestMojangApiTexturesProvider(t *testing.T) {
suite.Run(t, new(MojangApiTexturesProviderSuite))
}
type TexturesProviderWithInMemoryCacheSuite struct {
suite.Suite
Original *TexturesProviderMock
Provider *TexturesProviderWithInMemoryCache
}
func (s *TexturesProviderWithInMemoryCacheSuite) SetupTest() {
s.Original = &TexturesProviderMock{}
s.Provider, _ = NewTexturesProviderWithInMemoryCache(s.Original)
}
func (s *TexturesProviderWithInMemoryCacheSuite) TearDownTest() {
s.Original.AssertExpectations(s.T())
s.Provider.StopGC()
}
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithSuccessfulOriginalProviderResponse() {
ctx := context.Background()
s.Original.On("GetTextures", ctx, "uuid").Once().Return(signedTexturesResponse, nil)
// Do the call multiple times to ensure, that there will be only one call to the Original provider
for i := 0; i < 5; i++ {
result, err := s.Provider.GetTextures(ctx, "uuid")
s.Require().NoError(err)
s.Require().Same(signedTexturesResponse, result)
}
}
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithEmptyOriginalProviderResponse() {
s.Original.On("GetTextures", mock.Anything, "uuid").Once().Return(nil, nil)
// Do the call multiple times to ensure, that there will be only one call to the original provider
for i := 0; i < 5; i++ {
result, err := s.Provider.GetTextures(context.Background(), "uuid")
s.Require().NoError(err)
s.Require().Nil(result)
}
}
func (s *TexturesProviderWithInMemoryCacheSuite) TestGetTexturesWithErrorFromOriginalProvider() {
expectedErr := errors.New("mock error")
s.Original.On("GetTextures", mock.Anything, "uuid").Times(5).Return(nil, expectedErr)
// Do the call multiple times to ensure, that the error will not be cached and there will be a request on each call
for i := 0; i < 5; i++ {
result, err := s.Provider.GetTextures(context.Background(), "uuid")
s.Require().Same(expectedErr, err)
s.Require().Nil(result)
}
}
func TestTexturesProviderWithInMemoryCache(t *testing.T) {
suite.Run(t, new(TexturesProviderWithInMemoryCacheSuite))
}

View File

@@ -0,0 +1,111 @@
package mojang
import (
"context"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"ely.by/chrly/internal/otel"
)
type MojangUuidsStorage interface {
// The second argument must be returned as a incoming username in case,
// when cached result indicates that there is no Mojang user with provided username
GetUuidForMojangUsername(ctx context.Context, username string) (foundUuid string, foundUsername string, err error)
// An empty uuid value can be passed if the corresponding account has not been found
StoreMojangUuid(ctx context.Context, username string, uuid string) error
}
func NewUuidsProviderWithCache(o UuidsProvider, s MojangUuidsStorage) (*UuidsProviderWithCache, error) {
metrics, err := newUuidsProviderWithCacheMetrics(otel.GetMeter())
if err != nil {
return nil, err
}
return &UuidsProviderWithCache{
Provider: o,
Storage: s,
metrics: metrics,
}, nil
}
type UuidsProviderWithCache struct {
Provider UuidsProvider
Storage MojangUuidsStorage
metrics *uuidsProviderWithCacheMetrics
}
func (p *UuidsProviderWithCache) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
var uuid, foundUsername string
var err error
defer p.recordMetrics(ctx, uuid, foundUsername, err)
uuid, foundUsername, err = p.Storage.GetUuidForMojangUsername(ctx, username)
if err != nil {
return nil, err
}
if foundUsername != "" {
if uuid != "" {
return &ProfileInfo{Id: uuid, Name: foundUsername}, nil
}
return nil, nil
}
profile, err := p.Provider.GetUuid(ctx, username)
if err != nil {
return nil, err
}
freshUuid := ""
wellCasedUsername := username
if profile != nil {
freshUuid = profile.Id
wellCasedUsername = profile.Name
}
_ = p.Storage.StoreMojangUuid(ctx, wellCasedUsername, freshUuid)
return profile, nil
}
func (p *UuidsProviderWithCache) recordMetrics(ctx context.Context, uuid string, username string, err error) {
if err != nil {
return
}
if username != "" {
p.metrics.Hits.Add(ctx, 1)
} else {
p.metrics.Misses.Add(ctx, 1)
}
}
func newUuidsProviderWithCacheMetrics(meter metric.Meter) (*uuidsProviderWithCacheMetrics, error) {
m := &uuidsProviderWithCacheMetrics{}
var errors, err error
m.Hits, err = meter.Int64Counter(
"chrly.mojang.uuids.cache.hit",
metric.WithDescription("Number of Mojang UUIDs found in the local cache"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
m.Misses, err = meter.Int64Counter(
"chrly.mojang.uuids.cache.miss",
metric.WithDescription("Number of Mojang UUIDs missing from local cache"),
metric.WithUnit("1"),
)
errors = multierr.Append(errors, err)
return m, errors
}
type uuidsProviderWithCacheMetrics struct {
Hits metric.Int64Counter
Misses metric.Int64Counter
}

View File

@@ -0,0 +1,131 @@
package mojang
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)
var mockProfile = &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "UserName"}
type UuidsProviderMock struct {
mock.Mock
}
func (m *UuidsProviderMock) GetUuid(ctx context.Context, username string) (*ProfileInfo, error) {
args := m.Called(ctx, username)
var result *ProfileInfo
if casted, ok := args.Get(0).(*ProfileInfo); ok {
result = casted
}
return result, args.Error(1)
}
type MojangUuidsStorageMock struct {
mock.Mock
}
func (m *MojangUuidsStorageMock) GetUuidForMojangUsername(ctx context.Context, username string) (string, string, error) {
args := m.Called(ctx, username)
return args.String(0), args.String(1), args.Error(2)
}
func (m *MojangUuidsStorageMock) StoreMojangUuid(ctx context.Context, username string, uuid string) error {
m.Called(ctx, username, uuid)
return nil
}
type UuidsProviderWithCacheSuite struct {
suite.Suite
Original *UuidsProviderMock
Storage *MojangUuidsStorageMock
Provider *UuidsProviderWithCache
}
func (s *UuidsProviderWithCacheSuite) SetupTest() {
s.Original = &UuidsProviderMock{}
s.Storage = &MojangUuidsStorageMock{}
s.Provider, _ = NewUuidsProviderWithCache(s.Original, s.Storage)
}
func (s *UuidsProviderWithCacheSuite) TearDownTest() {
s.Original.AssertExpectations(s.T())
s.Storage.AssertExpectations(s.T())
}
func (s *UuidsProviderWithCacheSuite) TestUncachedSuccessfully() {
ctx := context.Background()
s.Storage.On("GetUuidForMojangUsername", ctx, "username").Return("", "", nil)
s.Storage.On("StoreMojangUuid", ctx, "UserName", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(nil)
s.Original.On("GetUuid", ctx, "username").Once().Return(mockProfile, nil)
result, err := s.Provider.GetUuid(ctx, "username")
s.Require().NoError(err)
s.Require().Equal(mockProfile, result)
}
func (s *UuidsProviderWithCacheSuite) TestUncachedNotExistsMojangUsername() {
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "", nil)
s.Storage.On("StoreMojangUuid", mock.Anything, "username", "").Once().Return(nil)
s.Original.On("GetUuid", mock.Anything, "username").Once().Return(nil, nil)
result, err := s.Provider.GetUuid(context.Background(), "username")
s.Require().NoError(err)
s.Require().Nil(result)
}
func (s *UuidsProviderWithCacheSuite) TestKnownCachedUsername() {
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("mock-uuid", "UserName", nil)
result, err := s.Provider.GetUuid(context.Background(), "username")
s.Require().NoError(err)
s.Require().NotNil(result)
s.Require().Equal("UserName", result.Name)
s.Require().Equal("mock-uuid", result.Id)
}
func (s *UuidsProviderWithCacheSuite) TestUnknownCachedUsername() {
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "UserName", nil)
result, err := s.Provider.GetUuid(context.Background(), "username")
s.Require().NoError(err)
s.Require().Nil(result)
}
func (s *UuidsProviderWithCacheSuite) TestErrorDuringCacheQuery() {
expectedError := errors.New("mock error")
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "", expectedError)
result, err := s.Provider.GetUuid(context.Background(), "username")
s.Require().Same(expectedError, err)
s.Require().Nil(result)
}
func (s *UuidsProviderWithCacheSuite) TestErrorFromOriginalProvider() {
expectedError := errors.New("mock error")
s.Storage.On("GetUuidForMojangUsername", mock.Anything, "username").Return("", "", nil)
s.Original.On("GetUuid", mock.Anything, "username").Once().Return(nil, expectedError)
result, err := s.Provider.GetUuid(context.Background(), "username")
s.Require().Same(expectedError, err)
s.Require().Nil(result)
}
func TestUuidsProviderWithCache(t *testing.T) {
suite.Run(t, new(UuidsProviderWithCacheSuite))
}

17
internal/otel/otel.go Normal file
View File

@@ -0,0 +1,17 @@
package otel
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
)
const Scope = "ely.by/chrly"
func GetMeter(opts ...metric.MeterOption) metric.Meter {
return otel.GetMeterProvider().Meter(Scope, opts...)
}
func GetTracer(opts ...trace.TracerOption) trace.Tracer {
return otel.GetTracerProvider().Tracer(Scope, opts...)
}

148
internal/otel/setup.go Normal file
View File

@@ -0,0 +1,148 @@
package otel
import (
"context"
"errors"
"log/slog"
"time"
"github.com/agoda-com/opentelemetry-go/otelslog"
logsOtel "github.com/agoda-com/opentelemetry-logs-go"
logsAutoconfig "github.com/agoda-com/opentelemetry-logs-go/autoconfigure/sdk/logs"
"github.com/agoda-com/opentelemetry-logs-go/sdk/logs"
"go.opentelemetry.io/contrib/exporters/autoexport"
runtimeMetrics "go.opentelemetry.io/contrib/instrumentation/runtime"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/semconv/v1.4.0"
"ely.by/chrly/internal/version"
)
func SetupOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error) {
var shutdownFuncs []func(context.Context) error
// shutdown calls cleanup functions registered via shutdownFuncs.
// The errors from the calls are joined.
// Each registered cleanup will be invoked once
shutdown = func(ctx context.Context) error {
var err error
for _, fn := range shutdownFuncs {
err = errors.Join(err, fn(ctx))
}
shutdownFuncs = nil
return err
}
// handleErr calls shutdown for cleanup and makes sure that all errors are returned
handleErr := func(inErr error) {
err = errors.Join(inErr, shutdown(ctx))
}
// Set up propagator
prop := newPropagator()
otel.SetTextMapPropagator(prop)
// Set up resource
res, err := newResource(ctx)
if err != nil {
handleErr(err)
return
}
// Set up logs provider
logsProvider, err := newLoggerProvider(ctx, res)
if err != nil {
handleErr(err)
return
}
shutdownFuncs = append(shutdownFuncs, logsProvider.Shutdown)
logsOtel.SetLoggerProvider(logsProvider)
otelSlog := slog.New(otelslog.NewOtelHandler(logsProvider, &otelslog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(otelSlog)
// Set up trace provider
tracerProvider, err := newTraceProvider(ctx, res)
if err != nil {
handleErr(err)
return
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
// Set up meter provider
meterProvider, err := newMeterProvider(ctx, res)
if err != nil {
handleErr(err)
return
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
err = runtimeMetrics.Start(runtimeMetrics.WithMinimumReadMemStatsInterval(time.Second))
if err != nil {
handleErr(err)
return
}
return
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
func newResource(ctx context.Context) (*resource.Resource, error) {
return resource.New(
ctx,
resource.WithFromEnv(),
resource.WithTelemetrySDK(),
resource.WithOS(),
resource.WithContainer(),
resource.WithHost(),
resource.WithAttributes(
semconv.ServiceNameKey.String("chrly"),
semconv.ServiceVersionKey.String(version.Version()),
),
)
}
func newLoggerProvider(ctx context.Context, res *resource.Resource) (*logs.LoggerProvider, error) {
return logsAutoconfig.NewLoggerProvider(ctx, logsAutoconfig.WithResource(res)), nil
}
func newTraceProvider(ctx context.Context, res *resource.Resource) (*trace.TracerProvider, error) {
exporter, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, err
}
return trace.NewTracerProvider(
trace.WithBatcher(exporter),
trace.WithResource(res),
), nil
}
func newMeterProvider(ctx context.Context, res *resource.Resource) (*metric.MeterProvider, error) {
reader, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, err
}
return metric.NewMeterProvider(
metric.WithReader(reader),
metric.WithResource(res),
), nil
}

View File

@@ -0,0 +1,122 @@
package profiles
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
"ely.by/chrly/internal/db"
)
type ProfilesRepository interface {
SaveProfile(ctx context.Context, profile *db.Profile) error
RemoveProfileByUuid(ctx context.Context, uuid string) error
}
func NewManager(pr ProfilesRepository) *Manager {
return &Manager{
ProfilesRepository: pr,
profileValidator: createProfileValidator(),
}
}
type Manager struct {
ProfilesRepository
profileValidator *validator.Validate
}
func (m *Manager) PersistProfile(ctx context.Context, profile *db.Profile) error {
validationErrors := m.profileValidator.Struct(profile)
if validationErrors != nil {
return mapValidationErrorsToCommonError(validationErrors.(validator.ValidationErrors))
}
profile.Uuid = cleanupUuid(profile.Uuid)
if profile.SkinUrl == "" || isClassicModel(profile.SkinModel) {
profile.SkinModel = ""
}
return m.ProfilesRepository.SaveProfile(ctx, profile)
}
func (m *Manager) RemoveProfileByUuid(ctx context.Context, uuid string) error {
return m.ProfilesRepository.RemoveProfileByUuid(ctx, cleanupUuid(uuid))
}
type ValidationError struct {
Errors map[string][]string
}
func (e *ValidationError) Error() string {
return "The profile is invalid and cannot be persisted"
}
func cleanupUuid(uuid string) string {
return strings.ReplaceAll(strings.ToLower(uuid), "-", "")
}
func createProfileValidator() *validator.Validate {
validate := validator.New()
regexUuidAny := regexp.MustCompile("(?i)^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$")
_ = validate.RegisterValidation("uuid_any", func(fl validator.FieldLevel) bool {
return regexUuidAny.MatchString(fl.Field().String())
})
regexUsername := regexp.MustCompile(`^[-\w.!$%^&*()\[\]:;]+$`)
_ = validate.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return regexUsername.MatchString(fl.Field().String())
})
validate.RegisterStructValidationMapRules(map[string]string{
"Username": "required,username,max=21",
"Uuid": "required,uuid_any",
"SkinUrl": "omitempty,url",
"SkinModel": "omitempty,max=20",
"CapeUrl": "omitempty,url",
"MojangTextures": "omitempty,base64",
"MojangSignature": "required_with=MojangTextures,omitempty,base64",
}, db.Profile{})
return validate
}
func mapValidationErrorsToCommonError(err validator.ValidationErrors) *ValidationError {
resultErr := &ValidationError{make(map[string][]string)}
for _, e := range err {
// Manager can return multiple errors per field, but the current validation implementation
// returns only one error per field
resultErr.Errors[e.Field()] = []string{formatValidationErr(e)}
}
return resultErr
}
// The go-playground/validator lib already contains tools for translated errors output.
// However, the implementation is very heavy and becomes even more so when you need to add messages for custom validators.
// So for simplicity, I've extracted validation error formatting into this simple implementation
func formatValidationErr(err validator.FieldError) string {
switch err.Tag() {
case "required", "required_with":
return fmt.Sprintf("%s is a required field", err.Field())
case "username":
return fmt.Sprintf("%s must be a valid username", err.Field())
case "max":
return fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param())
case "uuid_any":
return fmt.Sprintf("%s must be a valid UUID", err.Field())
case "url":
return fmt.Sprintf("%s must be a valid URL", err.Field())
case "base64":
return fmt.Sprintf("%s must be a valid Base64 string", err.Field())
default:
return fmt.Sprintf(`Field validation for "%s" failed on the "%s" tag`, err.Field(), err.Tag())
}
}
func isClassicModel(model string) bool {
return model == "" || model == "classic" || model == "default" || model == "steve"
}

View File

@@ -0,0 +1,130 @@
package profiles
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"ely.by/chrly/internal/db"
)
type ProfilesRepositoryMock struct {
mock.Mock
}
func (m *ProfilesRepositoryMock) SaveProfile(ctx context.Context, profile *db.Profile) error {
return m.Called(ctx, profile).Error(0)
}
func (m *ProfilesRepositoryMock) RemoveProfileByUuid(ctx context.Context, uuid string) error {
return m.Called(ctx, uuid).Error(0)
}
type ManagerTestSuite struct {
suite.Suite
Manager *Manager
ProfilesRepository *ProfilesRepositoryMock
}
func (t *ManagerTestSuite) SetupSubTest() {
t.ProfilesRepository = &ProfilesRepositoryMock{}
t.Manager = NewManager(t.ProfilesRepository)
}
func (t *ManagerTestSuite) TearDownSubTest() {
t.ProfilesRepository.AssertExpectations(t.T())
}
func (t *ManagerTestSuite) TestPersistProfile() {
t.Run("valid profile (full)", func() {
ctx := context.Background()
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "slim",
CapeUrl: "https://example.com/cape.png",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=",
}
t.ProfilesRepository.On("SaveProfile", ctx, profile).Once().Return(nil)
err := t.Manager.PersistProfile(ctx, profile)
t.NoError(err)
})
t.Run("valid profile (minimal)", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "mock-username",
}
t.ProfilesRepository.On("SaveProfile", mock.Anything, profile).Once().Return(nil)
err := t.Manager.PersistProfile(context.Background(), profile)
t.NoError(err)
})
t.Run("normalize uuid and skin model", func() {
profile := &db.Profile{
Uuid: "BA866A9C-C839-4268-A30F-7B26AE604C51",
Username: "mock-username",
SkinUrl: "https://example.com/skin.png",
SkinModel: "default",
}
expectedProfile := *profile
expectedProfile.Uuid = "ba866a9cc8394268a30f7b26ae604c51"
expectedProfile.SkinModel = ""
t.ProfilesRepository.On("SaveProfile", mock.Anything, &expectedProfile).Once().Return(nil)
err := t.Manager.PersistProfile(context.Background(), profile)
t.NoError(err)
})
t.Run("require mojangSignature when mojangTexturesProvided", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "mock-username",
MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=",
}
err := t.Manager.PersistProfile(context.Background(), profile)
t.Error(err)
t.IsType(&ValidationError{}, err)
castedErr := err.(*ValidationError)
mojangSignatureErr, mojangSignatureErrExists := castedErr.Errors["MojangSignature"]
t.True(mojangSignatureErrExists)
t.Contains(mojangSignatureErr[0], "required")
})
t.Run("validate username", func() {
profile := &db.Profile{
Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51",
Username: "invalid\"username",
}
err := t.Manager.PersistProfile(context.Background(), profile)
t.Error(err)
t.IsType(&ValidationError{}, err)
castedErr := err.(*ValidationError)
usernameErrs, usernameErrExists := castedErr.Errors["Username"]
t.True(usernameErrExists)
t.Contains(usernameErrs[0], "valid")
})
t.Run("empty profile", func() {
profile := &db.Profile{}
err := t.Manager.PersistProfile(context.Background(), profile)
t.Error(err)
t.IsType(&ValidationError{}, err)
// TODO: validate errors
})
}
func TestManager(t *testing.T) {
suite.Run(t, new(ManagerTestSuite))
}

View File

@@ -0,0 +1,96 @@
package profiles
import (
"context"
"errors"
"ely.by/chrly/internal/db"
"ely.by/chrly/internal/mojang"
)
type ProfilesFinder interface {
FindProfileByUsername(ctx context.Context, username string) (*db.Profile, error)
}
type MojangProfilesProvider interface {
GetForUsername(ctx context.Context, username string) (*mojang.ProfileResponse, error)
}
func NewProvider(pf ProfilesFinder, mpf MojangProfilesProvider) (*Provider, error) {
return &Provider{
ProfilesFinder: pf,
MojangProfilesProvider: mpf,
}, nil
}
type Provider struct {
ProfilesFinder
MojangProfilesProvider
}
func (p *Provider) FindProfileByUsername(ctx context.Context, username string, allowProxy bool) (*db.Profile, error) {
profile, err := p.ProfilesFinder.FindProfileByUsername(ctx, username)
if err != nil {
return nil, err
}
if profile != nil && (profile.SkinUrl != "" || profile.CapeUrl != "") {
return profile, nil
}
if allowProxy {
mojangProfile, err := p.MojangProfilesProvider.GetForUsername(ctx, username)
// If we at least know something about the user,
// then we can ignore an error and return profile without textures
if err != nil && profile != nil {
return profile, nil
}
if err != nil || mojangProfile == nil {
if errors.Is(err, mojang.InvalidUsername) {
return nil, nil
}
return nil, err
}
decodedTextures, err := mojangProfile.DecodeTextures()
if err != nil {
return nil, err
}
profile = &db.Profile{
Uuid: mojangProfile.Id,
Username: mojangProfile.Name,
}
// There might be no textures property
if decodedTextures != nil {
if decodedTextures.Textures.Skin != nil {
profile.SkinUrl = decodedTextures.Textures.Skin.Url
if decodedTextures.Textures.Skin.Metadata != nil {
profile.SkinModel = decodedTextures.Textures.Skin.Metadata.Model
}
}
if decodedTextures.Textures.Cape != nil {
profile.CapeUrl = decodedTextures.Textures.Cape.Url
}
}
var texturesProp *mojang.Property
for _, prop := range mojangProfile.Props {
if prop.Name == "textures" {
texturesProp = prop
break
}
}
if texturesProp != nil {
profile.MojangTextures = texturesProp.Value
profile.MojangSignature = texturesProp.Signature
}
}
return profile, nil
}

View File

@@ -0,0 +1,275 @@
package profiles
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"ely.by/chrly/internal/db"
"ely.by/chrly/internal/mojang"
"ely.by/chrly/internal/utils"
)
type ProfilesFinderMock struct {
mock.Mock
}
func (m *ProfilesFinderMock) FindProfileByUsername(ctx context.Context, username string) (*db.Profile, error) {
args := m.Called(ctx, username)
var result *db.Profile
if casted, ok := args.Get(0).(*db.Profile); ok {
result = casted
}
return result, args.Error(1)
}
type MojangProfilesProviderMock struct {
mock.Mock
}
func (m *MojangProfilesProviderMock) GetForUsername(ctx context.Context, username string) (*mojang.ProfileResponse, error) {
args := m.Called(ctx, username)
var result *mojang.ProfileResponse
if casted, ok := args.Get(0).(*mojang.ProfileResponse); ok {
result = casted
}
return result, args.Error(1)
}
type CombinedProfilesProviderSuite struct {
suite.Suite
Provider *Provider
ProfilesFinder *ProfilesFinderMock
MojangProfilesProvider *MojangProfilesProviderMock
}
func (t *CombinedProfilesProviderSuite) SetupSubTest() {
t.ProfilesFinder = &ProfilesFinderMock{}
t.MojangProfilesProvider = &MojangProfilesProviderMock{}
t.Provider, _ = NewProvider(
t.ProfilesFinder,
t.MojangProfilesProvider,
)
}
func (t *CombinedProfilesProviderSuite) TearDownSubTest() {
t.ProfilesFinder.AssertExpectations(t.T())
t.MojangProfilesProvider.AssertExpectations(t.T())
}
func (t *CombinedProfilesProviderSuite) TestFindByUsername() {
t.Run("exists profile with a skin", func() {
ctx := context.Background()
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
SkinUrl: "https://example.com/skin.png",
}
t.ProfilesFinder.On("FindProfileByUsername", ctx, "Mock").Return(profile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(ctx, "Mock", true)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("exists profile with a cape", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
CapeUrl: "https://example.com/cape.png",
}
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(profile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("exists profile without textures (no proxy)", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
}
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(profile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", false)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("not exists profile (no proxy)", func() {
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", false)
t.NoError(err)
t.Nil(foundProfile)
})
t.Run("handle error from profiles repository", func() {
expectedError := errors.New("mock error")
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, expectedError)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", false)
t.Same(expectedError, err)
t.Nil(foundProfile)
})
t.Run("exists profile without textures (with proxy)", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
}
mojangProfile := createMojangProfile(true, true)
ctx := context.Background()
t.ProfilesFinder.On("FindProfileByUsername", ctx, "Mock").Return(profile, nil)
t.MojangProfilesProvider.On("GetForUsername", ctx, "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(ctx, "Mock", true)
t.NoError(err)
t.Equal(&db.Profile{
Uuid: "mock-mojang-uuid",
Username: "mOcK",
SkinUrl: "https://mojang/skin.png",
SkinModel: "slim",
CapeUrl: "https://mojang/cape.png",
MojangTextures: mojangProfile.Props[0].Value,
MojangSignature: mojangProfile.Props[0].Signature,
}, foundProfile)
})
t.Run("not exists profile (with proxy)", func() {
mojangProfile := createMojangProfile(true, true)
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.NoError(err)
t.Equal(&db.Profile{
Uuid: "mock-mojang-uuid",
Username: "mOcK",
SkinUrl: "https://mojang/skin.png",
SkinModel: "slim",
CapeUrl: "https://mojang/cape.png",
MojangTextures: mojangProfile.Props[0].Value,
MojangSignature: mojangProfile.Props[0].Signature,
}, foundProfile)
})
t.Run("should return known profile without textures when received an error from the mojang", func() {
profile := &db.Profile{
Uuid: "mock-uuid",
Username: "Mock",
}
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(profile, nil)
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(nil, errors.New("mock error"))
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.NoError(err)
t.Same(profile, foundProfile)
})
t.Run("should not return an error when passed the invalid username", func() {
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(nil, mojang.InvalidUsername)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.NoError(err)
t.Nil(foundProfile)
})
t.Run("should return an error from mojang provider", func() {
expectedError := errors.New("mock error")
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(nil, expectedError)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.Same(expectedError, err)
t.Nil(foundProfile)
})
t.Run("should correctly handle invalid textures from mojang", func() {
mojangProfile := &mojang.ProfileResponse{
Props: []*mojang.Property{
{
Name: "textures",
Value: "this is invalid base64",
Signature: "mojang signature",
},
},
}
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.ErrorContains(err, "illegal base64 data")
t.Nil(foundProfile)
})
t.Run("should correctly handle missing textures property from Mojang", func() {
mojangProfile := &mojang.ProfileResponse{
Id: "mock-mojang-uuid",
Name: "mOcK",
Props: []*mojang.Property{},
}
t.ProfilesFinder.On("FindProfileByUsername", mock.Anything, "Mock").Return(nil, nil)
t.MojangProfilesProvider.On("GetForUsername", mock.Anything, "Mock").Return(mojangProfile, nil)
foundProfile, err := t.Provider.FindProfileByUsername(context.Background(), "Mock", true)
t.NoError(err)
t.Equal(&db.Profile{
Uuid: "mock-mojang-uuid",
Username: "mOcK",
}, foundProfile)
})
}
func TestProvider(t *testing.T) {
suite.Run(t, new(CombinedProfilesProviderSuite))
}
func createMojangProfile(withSkin bool, withCape bool) *mojang.ProfileResponse {
timeZone, _ := time.LoadLocation("Europe/Warsaw")
textures := &mojang.TexturesProp{
Timestamp: utils.UnixMillisecond(time.Date(2024, 1, 29, 13, 34, 12, 0, timeZone)),
ProfileID: "mock-mojang-uuid",
ProfileName: "mOcK",
Textures: &mojang.TexturesResponse{},
}
if withSkin {
textures.Textures.Skin = &mojang.SkinTexturesResponse{
Url: "https://mojang/skin.png",
Metadata: &mojang.SkinTexturesMetadata{
Model: "slim",
},
}
}
if withCape {
textures.Textures.Cape = &mojang.CapeTexturesResponse{
Url: "https://mojang/cape.png",
}
}
response := &mojang.ProfileResponse{
Id: textures.ProfileID,
Name: textures.ProfileName,
Props: []*mojang.Property{
{
Name: "textures",
Value: mojang.EncodeTextures(textures),
Signature: "mojang signature",
},
},
}
return response
}

106
internal/security/jwt.go Normal file
View File

@@ -0,0 +1,106 @@
package security
import (
"errors"
"fmt"
"net/http"
"slices"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"ely.by/chrly/internal/version"
)
var now = time.Now
var signingMethod = jwt.SigningMethodHS256
type Scope string
const (
ProfilesScope Scope = "profiles"
SignScope Scope = "sign"
)
var validScopes = []Scope{
ProfilesScope,
SignScope,
}
type claims struct {
jwt.RegisteredClaims
Scopes []Scope `json:"scopes"`
}
func NewJwt(key []byte) *Jwt {
return &Jwt{
Key: key,
}
}
type Jwt struct {
Key []byte
}
func (t *Jwt) NewToken(scopes ...Scope) (string, error) {
if len(scopes) == 0 {
return "", errors.New("you must specify at least one scope")
}
for _, scope := range scopes {
if !slices.Contains(validScopes, scope) {
return "", fmt.Errorf("unknown scope %s", scope)
}
}
token := jwt.New(signingMethod)
token.Claims = &claims{
jwt.RegisteredClaims{
Issuer: "chrly",
IssuedAt: jwt.NewNumericDate(now()),
},
scopes,
}
token.Header["v"] = version.MajorVersion
return token.SignedString(t.Key)
}
// Keep those names generic in order to reuse them in future for alternative authentication methods
var MissingAuthenticationError = errors.New("authentication value not provided")
var InvalidTokenError = errors.New("passed authentication value is invalid")
func (t *Jwt) Authenticate(req *http.Request, scope Scope) error {
bearerToken := req.Header.Get("Authorization")
if bearerToken == "" {
return MissingAuthenticationError
}
if !strings.HasPrefix(strings.ToLower(bearerToken), "bearer ") {
return InvalidTokenError
}
tokenStr := bearerToken[7:] // trim "bearer " part
token, err := jwt.ParseWithClaims(tokenStr, &claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return t.Key, nil
})
if err != nil {
return errors.Join(InvalidTokenError, err)
}
if _, vHeaderExists := token.Header["v"]; !vHeaderExists {
return errors.Join(InvalidTokenError, errors.New("missing v header"))
}
claims := token.Claims.(*claims)
if !slices.Contains(claims.Scopes, scope) {
return errors.New("the token doesn't have the scope to perform the action")
}
return nil
}

View File

@@ -0,0 +1,88 @@
package security
import (
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
const jwtString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInYiOjV9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIl19.LrXrKo5iRFFHCDlMsVDhmJJheZqxbxuEVXB4XswHFKY"
func TestJwtAuth_NewToken(t *testing.T) {
jwt := NewJwt([]byte("secret"))
now = func() time.Time {
return time.Date(2024, 2, 1, 11, 26, 15, 0, time.UTC)
}
t.Run("with known scope", func(t *testing.T) {
token, err := jwt.NewToken(ProfilesScope, SignScope)
require.NoError(t, err)
require.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsInYiOjV9.eyJpc3MiOiJjaHJseSIsImlhdCI6MTcwNjc4Njc3NSwic2NvcGVzIjpbInByb2ZpbGVzIiwic2lnbiJdfQ.HkNGiDba3I_bLGN6sF0eTE5n6rMLgYfAZZEqI4xb2X4", token)
})
t.Run("with unknown scope", func(t *testing.T) {
token, err := jwt.NewToken("scope-123")
require.ErrorContains(t, err, "unknown")
require.Empty(t, token)
})
t.Run("no scopes", func(t *testing.T) {
token, err := jwt.NewToken()
require.Error(t, err)
require.Empty(t, token)
})
}
func TestJwtAuth_Authenticate(t *testing.T) {
jwt := NewJwt([]byte("secret"))
t.Run("success", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer "+jwtString)
err := jwt.Authenticate(req, ProfilesScope)
require.NoError(t, err)
})
t.Run("has no required scope", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer "+jwtString)
err := jwt.Authenticate(req, SignScope)
require.ErrorContains(t, err, "scope")
})
t.Run("request without auth header", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
err := jwt.Authenticate(req, ProfilesScope)
require.ErrorIs(t, err, MissingAuthenticationError)
})
t.Run("no bearer token prefix", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "trash")
err := jwt.Authenticate(req, ProfilesScope)
require.ErrorIs(t, err, InvalidTokenError)
})
t.Run("bearer token but not jwt", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer seems.like.jwt")
err := jwt.Authenticate(req, ProfilesScope)
require.ErrorIs(t, err, InvalidTokenError)
})
t.Run("invalid signature", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer "+jwtString+"123")
err := jwt.Authenticate(req, ProfilesScope)
require.ErrorIs(t, err, InvalidTokenError)
})
t.Run("missing v header", func(t *testing.T) {
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDY3ODY3NzUsImlzcyI6ImNocmx5Iiwic2NvcGVzIjpbInByb2ZpbGVzIl19.zOX2ZKyU37kjwt1p9uCHxALxWQD2UC0wWcAcNvBXGq0")
err := jwt.Authenticate(req, ProfilesScope)
require.ErrorIs(t, err, InvalidTokenError)
require.ErrorContains(t, err, "missing v header")
})
}

47
internal/utils/queue.go Normal file
View File

@@ -0,0 +1,47 @@
package utils
import (
"sync"
)
type Queue[T any] struct {
lock sync.Mutex
items []T
}
func NewQueue[T any]() *Queue[T] {
return &Queue[T]{
items: []T{},
}
}
func (s *Queue[T]) Enqueue(item T) int {
s.lock.Lock()
defer s.lock.Unlock()
s.items = append(s.items, item)
return len(s.items)
}
func (s *Queue[T]) Dequeue(n int) ([]T, int) {
s.lock.Lock()
defer s.lock.Unlock()
l := len(s.items)
if n > l {
n = l
}
items := s.items[0:n]
s.items = s.items[n:l]
return items, l - n
}
func (s *Queue[T]) Len() int {
s.lock.Lock()
defer s.lock.Unlock()
return len(s.items)
}

View File

@@ -0,0 +1,47 @@
package utils
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestQueue(t *testing.T) {
t.Run("Enqueue", func(t *testing.T) {
s := NewQueue[string]()
require.Equal(t, 1, s.Enqueue("username1"))
require.Equal(t, 2, s.Enqueue("username2"))
require.Equal(t, 3, s.Enqueue("username3"))
})
t.Run("Dequeue", func(t *testing.T) {
s := NewQueue[string]()
s.Enqueue("username1")
s.Enqueue("username2")
s.Enqueue("username3")
s.Enqueue("username4")
s.Enqueue("username5")
items, queueLen := s.Dequeue(2)
require.Len(t, items, 2)
require.Equal(t, 3, queueLen)
require.Equal(t, "username1", items[0])
require.Equal(t, "username2", items[1])
items, queueLen = s.Dequeue(40)
require.Len(t, items, 3)
require.Equal(t, 0, queueLen)
require.Equal(t, "username3", items[0])
require.Equal(t, "username4", items[1])
require.Equal(t, "username5", items[2])
})
t.Run("Len", func(t *testing.T) {
s := NewQueue[string]()
s.Enqueue("username1")
s.Enqueue("username2")
s.Enqueue("username3")
require.Equal(t, 3, s.Len())
})
}

7
internal/utils/time.go Normal file
View File

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

View File

@@ -0,0 +1,16 @@
package utils
import (
"time"
"testing"
assert "github.com/stretchr/testify/require"
)
func TestUnixMillisecond(t *testing.T) {
loc, _ := time.LoadLocation("CET")
d := time.Date(2021, 02, 26, 00, 43, 57, 987654321, loc)
assert.Equal(t, int64(1614296637987), UnixMillisecond(d))
}

View File

@@ -0,0 +1,16 @@
package version
const MajorVersion = 5
var (
version = "undefined"
commit = "unknown"
)
func Version() string {
return version
}
func Commit() string {
return commit
}

View File

@@ -1,11 +0,0 @@
package data
import "fmt"
type DataNotFound struct {
Who string
}
func (e DataNotFound) Error() string {
return fmt.Sprintf("Skin data not found. Required username \"%v\"", e.Who)
}

View File

@@ -1,44 +0,0 @@
package data
import (
"log"
"encoding/json"
"elyby/minecraft-skinsystem/lib/services"
"elyby/minecraft-skinsystem/lib/tools"
"github.com/mediocregopher/radix.v2/redis"
)
type SkinItem struct {
UserId int `json:"userId"`
Username string `json:"username"`
SkinId int `json:"skinId"`
Url string `json:"url"`
Is1_8 bool `json:"is1_8"`
IsSlim bool `json:"isSlim"`
Hash string `json:"hash"`
}
func (s *SkinItem) Save() {
str, _ := json.Marshal(s)
services.RedisPool.Cmd("SET", tools.BuildKey(s.Username), str)
}
func FindRecord(username string) (SkinItem, error) {
var record SkinItem;
response := services.RedisPool.Cmd("GET", tools.BuildKey(username));
if (response.IsType(redis.Nil)) {
return record, DataNotFound{username}
}
result, err := response.Str()
if (err == nil) {
decodeErr := json.Unmarshal([]byte(result), &record)
if (decodeErr != nil) {
log.Println("Cannot decode record data")
}
}
return record, err
}

View File

@@ -1,15 +0,0 @@
package data
type TexturesResponse struct {
Skin *Skin `json:"SKIN"`
}
type Skin struct {
Url string `json:"url"`
Hash string `json:"hash"`
Metadata *SkinMetadata `json:"metadata,omitempty"`
}
type SkinMetadata struct {
Model string `json:"model"`
}

View File

@@ -1,27 +0,0 @@
package routes
import (
"log"
"net/http"
"github.com/gorilla/mux"
"elyby/minecraft-skinsystem/lib/tools"
)
func Cape(w http.ResponseWriter, r *http.Request) {
username := tools.ParseUsername(mux.Vars(r)["username"])
log.Println("request cape for username " + username)
http.Redirect(w, r, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
}
func CapeGET(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("name")
if username == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(r)["username"] = username
Cape(w, r)
}

View File

@@ -1,28 +0,0 @@
package routes
import (
"net/http"
"github.com/gorilla/mux"
)
// Метод-наследие от первой версии системы скинов.
// Всё ещё иногда используется
// Просто конвертируем данные и отправляем их в основной обработчик
func MinecraftPHP(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("name")
required := r.URL.Query().Get("type")
if username == "" || required == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(r)["username"] = username
switch required {
case "skin": Skin(w, r)
case "cloack": Cape(w, r)
default: {
w.WriteHeader(http.StatusNotFound)
}
}
}

View File

@@ -1,18 +0,0 @@
package routes
import (
"net/http"
"encoding/json"
)
func NotFound(w http.ResponseWriter, r *http.Request) {
json, _ := json.Marshal(map[string]string{
"status": "404",
"message": "Not Found",
"link": "http://docs.ely.by/skin-system.html",
})
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write(json)
}

View File

@@ -1,29 +0,0 @@
package routes
import (
"net/http"
"strconv"
"elyby/minecraft-skinsystem/lib/data"
)
func SetSkin(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-Ely-key")
if key != "43fd2ce61b3f5704dfd729c1f2d6ffdb" {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Nice try"))
return
}
skin := new(data.SkinItem)
skin.Username = r.PostFormValue("username")
skin.UserId, _ = strconv.Atoi(r.PostFormValue("userId"))
skin.SkinId, _ = strconv.Atoi(r.PostFormValue("skinId"))
skin.Hash = r.PostFormValue("hash")
skin.Is1_8, _ = strconv.ParseBool(r.PostFormValue("is1_8"))
skin.IsSlim, _ = strconv.ParseBool(r.PostFormValue("isSlim"))
skin.Url = r.PostFormValue("url")
skin.Save()
w.Write([]byte("OK"))
}

View File

@@ -1,34 +0,0 @@
package routes
import (
"log"
"net/http"
"github.com/gorilla/mux"
"elyby/minecraft-skinsystem/lib/tools"
"elyby/minecraft-skinsystem/lib/data"
)
func Skin(w http.ResponseWriter, r *http.Request) {
username := tools.ParseUsername(mux.Vars(r)["username"])
log.Println("request skin for username " + username);
rec, err := data.FindRecord(username)
if (err != nil) {
http.Redirect(w, r, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301)
return
}
http.Redirect(w, r, tools.BuildElyUrl(rec.Url), 301);
}
func SkinGET(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("name")
if username == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(r)["username"] = username
Skin(w, r)
}

View File

@@ -1,42 +0,0 @@
package routes
import (
"log"
"net/http"
"encoding/json"
"github.com/gorilla/mux"
"elyby/minecraft-skinsystem/lib/data"
"elyby/minecraft-skinsystem/lib/tools"
)
func Textures(w http.ResponseWriter, r *http.Request) {
username := tools.ParseUsername(mux.Vars(r)["username"])
log.Println("request textures for username " + username)
rec, err := data.FindRecord(username)
if (err != nil || rec.SkinId == 0) {
rec.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png"
rec.Hash = string(tools.BuildNonElyTexturesHash(username))
} else {
rec.Url = tools.BuildElyUrl(rec.Url)
}
textures := data.TexturesResponse{
Skin: &data.Skin{
Url: rec.Url,
Hash: rec.Hash,
},
}
if (rec.IsSlim) {
textures.Skin.Metadata = &data.SkinMetadata{
Model: "slim",
}
}
response,_ := json.Marshal(textures)
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}

View File

@@ -1,10 +0,0 @@
package services
import (
"github.com/mediocregopher/radix.v2/pool"
"github.com/gorilla/mux"
)
var RedisPool *pool.Pool
var Router *mux.Router

View File

@@ -1,40 +0,0 @@
package tools
import (
"strings"
"time"
"crypto/md5"
"strconv"
"encoding/hex"
)
func ParseUsername(username string) string {
const suffix = ".png"
if strings.HasSuffix(username, suffix) {
username = strings.TrimSuffix(username, suffix)
}
return username
}
func BuildNonElyTexturesHash(username string) string {
hour := getCurrentHour()
hasher := md5.New()
hasher.Write([]byte("non-ely-" + strconv.FormatInt(hour, 10) + "-" + username))
return hex.EncodeToString(hasher.Sum(nil))
}
func BuildKey(username string) string {
return "username:" + strings.ToLower(username)
}
func BuildElyUrl(route string) string {
return "http://ely.by" + route
}
func getCurrentHour() int64 {
n := time.Now()
return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), 0, 0, 0, time.UTC).Unix()
}

View File

@@ -1,22 +0,0 @@
package tools_test
import (
"testing"
. "elyby/minecraft-skinsystem/lib/tools"
)
func TestParseUsername(t *testing.T) {
if ParseUsername("test.png") != "test" {
t.Error("Function should trim .png at end")
}
if ParseUsername("test") != "test" {
t.Error("Function should return string itself, if it not contains .png at end")
}
}
func TestBuildKey(t *testing.T) {
if BuildKey("Test") != "username:test" {
t.Error("Function shound convert string to lower case and concatenate it with usernmae:")
}
}

View File

@@ -1,68 +0,0 @@
package main
import (
"log"
"runtime"
//"time"
"net/http"
"github.com/gorilla/mux"
"github.com/mediocregopher/radix.v2/pool"
"elyby/minecraft-skinsystem/lib/routes"
"elyby/minecraft-skinsystem/lib/services"
//"github.com/mediocregopher/radix.v2/redis"
)
const redisString string = "redis:6379"
func main() {
log.Println("Starting...")
runtime.GOMAXPROCS(runtime.NumCPU())
log.Println("Connecting to redis")
redisPool, redisErr := pool.New("tcp", redisString, 10)
if redisErr != nil {
log.Fatal("Redis unavailable")
}
log.Println("Connected to redis")
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", routes.Skin).Methods("GET").Name("skins")
router.HandleFunc("/cloaks/{username}", routes.Cape).Methods("GET").Name("cloaks")
router.HandleFunc("/textures/{username}", routes.Textures).Methods("GET").Name("textures")
// Legacy
router.HandleFunc("/minecraft.php", routes.MinecraftPHP).Methods("GET")
router.HandleFunc("/skins/", routes.SkinGET).Methods("GET")
router.HandleFunc("/cloaks/", routes.CapeGET).Methods("GET")
// 404
router.NotFoundHandler = http.HandlerFunc(routes.NotFound)
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.HandleFunc("/user/{username}/skin", routes.SetSkin).Methods("POST")
services.RedisPool = redisPool
services.Router = router
/*go func() {
for {
time.Sleep(5 * time.Second)
resp := services.Redis.Cmd("PING")
if (resp.Err != nil) {
log.Println("Redis not pinged. Try to reconnect")
newClient, redisErr := redis.Dial("tcp", redisString)
if (redisErr != nil) {
log.Println("Cannot reconnect to redis")
} else {
services.Redis = newClient
log.Println("Reconnected")
}
}
}
}()*/
log.Println("Started");
log.Fatal(http.ListenAndServe(":80", router))
}