Compare commits

...

212 Commits
4.1.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
106 changed files with 6823 additions and 3333 deletions

View File

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

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

16
.gitignore vendored
View File

@@ -1,5 +1,11 @@
.idea
docker-compose.yml
docker-compose.override.yml
vendor
.cover
# IDE files
.idea/
*.iml
.vscode
# Go mod vendoring
/vendor
# Local environment
/docker-compose.yml
/data

View File

@@ -1,41 +0,0 @@
sudo: required
language: go
go:
- 1.9
services:
- docker
stages:
- test
- name: deploy
if: branch = master OR tag IS present
install:
- go get -u github.com/golang/dep/cmd/dep
- dep ensure
jobs:
include:
- stage: test
script:
- go test -v -race ./...
- stage: deploy
script:
- docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
- export DOCKER_TAG="${TRAVIS_TAG:-dev}"
- export APP_VERSION="${TRAVIS_TAG:-dev-${TRAVIS_COMMIT:0:7}}"
- >
env CGO_ENABLED=0 GOOS=linux GOARCH=amd64
go build
-o release/chrly
-ldflags '-extldflags "-static" -X github.com/elyby/chrly/bootstrap.version=$APP_VERSION'
main.go
- docker build -t elyby/chrly:$DOCKER_TAG .
- docker push elyby/chrly:$DOCKER_TAG
- |
if [ ! -z ${TRAVIS_TAG+x} ] && [[ "$TRAVIS_TAG" != *"-"* ]]; then
docker tag elyby/chrly:$DOCKER_TAG elyby/chrly:latest
docker push elyby/chrly:latest
fi

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,12 +0,0 @@
FROM alpine:3.7
EXPOSE 80
ENV STORAGE_REDIS_HOST=redis
ENV STORAGE_FILESYSTEM_HOST=/data
COPY docker-entrypoint.sh /usr/local/bin/
COPY release/chrly /usr/local/bin/
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["serve"]

184
Gopkg.lock generated
View File

@@ -1,184 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/SermoDigital/jose"
packages = [".","crypto","jws","jwt"]
revision = "f6df55f235c24f236d11dbcf665249a59ac2021f"
version = "1.1"
[[projects]]
name = "github.com/certifi/gocertifi"
packages = ["."]
revision = "3fd9e1adb12b72d2f3f82191d49be9b93c69f67c"
version = "2017.07.27"
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
name = "github.com/fsnotify/fsnotify"
packages = ["."]
revision = "629574ca2a5df945712d3079857300b5e4da0236"
version = "v1.4.2"
[[projects]]
branch = "master"
name = "github.com/getsentry/raven-go"
packages = ["."]
revision = "d175f85701dfbf44cb0510114c9943e665e60907"
[[projects]]
name = "github.com/golang/mock"
packages = ["gomock"]
revision = "13f360950a79f5864a972c786a10a50e44b69541"
version = "v1.0.0"
[[projects]]
name = "github.com/gorilla/context"
packages = ["."]
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
version = "v1.1"
[[projects]]
name = "github.com/gorilla/mux"
packages = ["."]
revision = "bcd8bc72b08df0f70df986b97f95590779502d31"
version = "v1.4.0"
[[projects]]
branch = "master"
name = "github.com/hashicorp/hcl"
packages = [".","hcl/ast","hcl/parser","hcl/scanner","hcl/strconv","hcl/token","json/parser","json/scanner","json/token"]
revision = "8f6b1344a92ff8877cf24a5de9177bf7d0a2a187"
[[projects]]
name = "github.com/inconshreveable/mousetrap"
packages = ["."]
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
version = "v1.0"
[[projects]]
name = "github.com/magiconair/properties"
packages = ["."]
revision = "be5ece7dd465ab0765a9682137865547526d1dfb"
version = "v1.7.3"
[[projects]]
branch = "master"
name = "github.com/mediocregopher/radix.v2"
packages = ["cluster","pool","redis","util"]
revision = "d234cfb904a91daafa4e1f92599a893b349cc0c2"
[[projects]]
branch = "master"
name = "github.com/mitchellh/mapstructure"
packages = ["."]
revision = "d0303fe809921458f417bcf828397a65db30a7e4"
[[projects]]
branch = "master"
name = "github.com/mono83/slf"
packages = [".","filters","params","rays","recievers","recievers/sentry","recievers/statsd","recievers/writer","wd"]
revision = "79153e9636db86e1c6b74d74dd04176f257a4f2d"
[[projects]]
branch = "master"
name = "github.com/mono83/udpwriter"
packages = ["."]
revision = "a064bd7e3acfda563ea680b913b9ef24b7a73e15"
[[projects]]
name = "github.com/pelletier/go-buffruneio"
packages = ["."]
revision = "c37440a7cf42ac63b919c752ca73a85067e05992"
version = "v0.2.0"
[[projects]]
name = "github.com/pelletier/go-toml"
packages = ["."]
revision = "5ccdfb18c776b740aecaf085c4d9a2779199c279"
version = "v1.0.0"
[[projects]]
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
branch = "master"
name = "github.com/spf13/afero"
packages = [".","mem"]
revision = "ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b"
[[projects]]
name = "github.com/spf13/cast"
packages = ["."]
revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "github.com/spf13/cobra"
packages = ["."]
revision = "0c34d16c3123764e413b9ed982ada58b1c3d53ea"
[[projects]]
branch = "master"
name = "github.com/spf13/jwalterweatherman"
packages = ["."]
revision = "12bd96e66386c1960ab0f74ced1362f66f552f7b"
[[projects]]
name = "github.com/spf13/pflag"
packages = ["."]
revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66"
version = "v1.0.0"
[[projects]]
name = "github.com/spf13/viper"
packages = ["."]
revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7"
version = "v1.0.0"
[[projects]]
name = "github.com/stretchr/testify"
packages = ["assert"]
revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0"
version = "v1.1.4"
[[projects]]
branch = "issue-18"
name = "github.com/thedevsaddam/govalidator"
packages = ["."]
revision = "59055296916bb3c6ad9cf3b21d5f2cf7059f8e76"
source = "https://github.com/erickskrauch/govalidator.git"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix"]
revision = "7ddbeae9ae08c6a06a59597f0c9edbc5ff2444ce"
[[projects]]
branch = "master"
name = "golang.org/x/text"
packages = ["internal/gen","internal/triegen","internal/ucd","transform","unicode/cldr","unicode/norm"]
revision = "bd91bbf73e9a4a801adbfb97133c992678533126"
[[projects]]
branch = "v2"
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "e6bd87f630333e3e5b03bea33720c3281a9094551bd5ced436062157fe51ab71"
solver-name = "gps-cdcl"
solver-version = 1

View File

@@ -1,44 +0,0 @@
ignored = ["github.com/elyby/chrly"]
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.4.0"
[[constraint]]
name = "github.com/mediocregopher/radix.v2"
[[constraint]]
name = "github.com/mono83/slf"
[[constraint]]
name = "github.com/spf13/cobra"
branch = "master"
[[constraint]]
name = "github.com/spf13/viper"
[[constraint]]
name = "github.com/getsentry/raven-go"
[[constraint]]
name = "github.com/SermoDigital/jose"
version = "~1.1.0"
[[constraint]]
name = "github.com/thedevsaddam/govalidator"
source = "https://github.com/erickskrauch/govalidator.git"
branch = "issue-18"
# Testing dependencies
[[constraint]]
name = "github.com/stretchr/testify"
version = "^1.1.4"
[[constraint]]
name = "github.com/golang/mock"
version = "^1.0.0"
[[constraint]]
name = "gopkg.in/h2non/gock.v1"
version = "^1.0.6"

View File

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

293
README.md
View File

@@ -1,14 +1,22 @@
# Chrly
Chrly is a lightweight implementation of Minecraft skins system server. It's packaged and distributed as a Docker
image and can be downloaded from [Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can
withstand heavy loads and is production ready.
[![Written in Go][ico-lang]][link-go]
[![Build Status][ico-build]][link-build]
[![Coverage][ico-coverage]][link-coverage]
[![Keep a Changelog][ico-changelog]](CHANGELOG.md)
[![Software License][ico-license]](LICENSE)
Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's
skins system. It's packaged and distributed as a Docker image and can be downloaded from
[Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can withstand heavy loads and is
production ready.
## Installation
You can easily install Chrly using [docker-compose](https://docs.docker.com/compose/). The configuration below (save
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` environment variable
that you must set before running `docker-compose up -d`. Other possible variables are described below.
it as `docker-compose.yml`) can be used to start a Chrly server. It relies on `CHRLY_SECRET` and `CHRLY_SIGNING_KEY`
environment variables that you must set before running `docker-compose up -d`. Other possible variables are described
below.
```yml
version: '2'
@@ -25,6 +33,7 @@ services:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=
redis:
image: redis:4.0-32bit
@@ -33,44 +42,147 @@ services:
- ./data/redis:/data
```
Chrly will mount some volumes on the host machine to persist storage for capes and Redis database.
**Tip**: to generate a value for the `CHRLY_SIGNING_KEY` use the command below and then join it with a `base64:` prefix.
```sh
openssl genrsa 4096 | base64 -w0
```
Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to
the host machine to do not lose data on container recreations.
### Config
Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key
inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated.
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `stop`
and `up` it:
If environment variables have been changed, Docker will automatically recreate the container, so you only need to `up`
it again:
```sh
docker-compose stop app
docker-compose up -d app
```
**Variables to adjust:**
| ENV | Description | Example |
|--------------------|------------------------------------------------------------------------------------|-------------------------------------------|
| STORAGE_REDIS_POOL | By default, Chrly creates pool with 10 connection, but you may want to increase it | `20` |
| STATSD_ADDR | StatsD can be used to collect metrics | `localhost:8125` |
| SENTRY_DSN | Sentry can be used to collect app errors | `https://public:private@your.sentry.io/1` |
<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. `.png` part can be omitted too.
Each endpoint that accepts `username` as a part of an url takes it case-insensitive. The `.png` postfix can be omitted.
#### `GET /skins/{username}.png`
This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll
respond with the `301` redirect to that url. If there is no record for requested username, it'll redirect to the
Mojang skins system as: `http://skins.minecraft.net/MinecraftSkins/{username}.png` with the original username's case.
respond with the `301` redirect to that url. If the skin entry isn't found, it'll request textures information from
Mojang's API and if it has a skin, than it'll return a `301` redirect to it.
#### `GET /cloaks/{username}.png`
It responds to requested `username` with a cape texture. If user's cape file doesn't exists, then it'll redirect to the
Mojang skins system as: `http://skins.minecraft.net/MinecraftCloaks/{username}.png` with the original username's case.
It responds to requested `username` with a cape texture. If the cape entry isn't found, it'll request textures
information from Mojang's API and if it has a cape, than it'll return a `301` redirect to it.
#### `GET /textures/{username}`
@@ -79,32 +191,93 @@ This endpoint forms response payloads as if it was the `textures`' property, but
```json
{
"SKIN": {
"url": "http://ely.by/minecraft/skins/skin.png",
"hash": "55d2a8848764f5ff04012cdb093458bd",
"url": "http://example.com/skin.png",
"metadata": {
"model": "slim"
}
},
"CAPE": {
"url": "http://skinsystem.ely.by/cloaks/username",
"hash": "424ff79dce9940af89c28ad80de8aaad"
"url": "http://example.com/cape.png"
}
}
```
If record for the requested username wasn't found, cape would be omitted and skin would be formed for Mojang skins
system. Hash would be formed as the username plus the half-hour-ranged time of request, which is needed to improve
caching of Mojang skins inside Minecraft.
If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has
a textures property, than it'll return decoded contents.
That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined
operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request
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, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if
you have your own source of the Mojang signatures, then you can pass it with textures and it'll be displayed in this
method. Received response should be directly sent to the client without any modification via game server API.
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:
@@ -128,6 +301,10 @@ Response example:
If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent.
You can adjust URL to `/textures/signed/{username}?proxy=true` to obtain textures information for provided username
from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in
the example above.
#### `GET /skins?name={username}`
Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username
@@ -151,10 +328,9 @@ You can obtain token by executing `docker-compose run --rm app token`.
#### `POST /api/skins`
> **Warning**: skin uploading via `skin` field is not implemented for now.
Endpoint allows you to create or update skin record for a username.
Endpoint allows you to create or update skin record for a username. To upload skin, you have to send multipart
form data. `form-urlencoded` also supported, but, as you may know, it doesn't support files uploading.
The request body must be encoded as `application/x-www-form-urlencoded`.
**Request params:**
@@ -164,13 +340,13 @@ form data. `form-urlencoded` also supported, but, as you may know, it doesn't su
| username | string | Username. Case insensitive. |
| uuid | uuid | UUID of the user. |
| skinId | int | Skin identifier. |
| hash | string | Skin's hash. Algorithm can be any. For example `md5`. |
| is1_8 | bool | Does the skin have the new format (64x64). |
| isSlim | bool | Does skin have slim arms (Alex model). |
| mojangTextures | string | Mojang textures field. It must be a base64 encoded json string. Not required. |
| mojangSignature | string | Signature for Mojang textures, which is required when `mojangTextures` passed. |
| url | string | Actual url of the skin. You have to pass this parameter or `skin`. |
| skin | file | Skin file. You have to pass this parameter or `url`. |
| url | string | Actual url of the skin. |
**Important**: all parameters are always read at least as their default values. So, if you only want to update the username and not pass the skin data it will reset all skin information. If you want to keep the data, you should always pass the full set of parameters.
If successful you'll receive `201` status code. In the case of failure there will be `400` status code and errors list
as json:
@@ -207,23 +383,44 @@ response will be:
}
```
### Health check
#### `GET /healthcheck`
This endpoint can be used to programmatically check the status of the server.
If all internal checks are successful, the server will return `200` status code with the following body:
```json
{
"status": "OK"
}
```
If any of the checks fails, the server will return `503` status code with the following body:
```json
{
"status": "Service Unavailable",
"errors": {
"mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded"
}
}
```
## Development
First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH`
environment variable.
This project uses [`dep`](https://github.com/golang/dep) for dependencies management, so it
[should be installed](https://github.com/golang/dep#installation) too.
Then you must fork this repository. Now follow these steps:
```sh
# Get the source code
go get github.com/elyby/chrly
git clone https://github.com/elyby/chrly.git
# Switch to the project folder
cd $GOPATH/src/github.com/elyby/chrly
# Install dependencies (it can take a while)
dep ensure
cd chrly
# Install dependencies
go mod download
# Add your fork link as a remote
git remote add fork git@github.com:your-username/chrly.git
# Create a new branch for your task
@@ -249,4 +446,14 @@ If your Redis instance isn't located at the `localhost`, you can change host by
`STORAGE_REDIS_HOST`.
After all of that `go run main.go serve` should successfully start the application.
To run tests execute `go test ./...`. If your Go version is older than 1.9, then run a `/script/test`.
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

View File

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

View File

@@ -1,97 +0,0 @@
package auth
import (
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
)
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxNTE2NjU4MTkzIiwic2NvcGVzIjoic2tpbiJ9.agbBS0qdyYMBaVfTZJAZcTTRgW1Y0kZty4H3N2JHBO8"
func TestJwtAuth_NewToken_Success(t *testing.T) {
assert := testify.New(t)
jwt := &JwtAuth{[]byte("secret")}
token, err := jwt.NewToken(SkinScope)
assert.Nil(err)
assert.NotNil(token)
}
func TestJwtAuth_NewToken_KeyNotAvailable(t *testing.T) {
assert := testify.New(t)
jwt := &JwtAuth{}
token, err := jwt.NewToken(SkinScope)
assert.Error(err, "signing key not available")
assert.Nil(token)
}
func TestJwtAuth_Check_EmptyRequest(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Authentication header not presented")
}
func TestJwtAuth_Check_NonBearer(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "this is not jwt")
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Cannot recognize JWT token in passed value")
}
func TestJwtAuth_Check_BearerButNotJwt(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer thisIs.Not.Jwt")
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "Cannot parse passed JWT token")
}
func TestJwtAuth_Check_SecretNotAvailable(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{}
err := jwt.Check(req)
assert.Error(err, "Signing key not set")
}
func TestJwtAuth_Check_SecretInvalid(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{[]byte("this is another secret")}
err := jwt.Check(req)
assert.IsType(&Unauthorized{}, err)
assert.EqualError(err, "JWT token have invalid signature. It may be corrupted or expired.")
}
func TestJwtAuth_Check_Valid(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("POST", "http://localhost", nil)
req.Header.Add("Authorization", "Bearer " + jwt)
jwt := &JwtAuth{[]byte("secret")}
err := jwt.Check(req)
assert.Nil(err)
}

View File

@@ -1,64 +0,0 @@
package bootstrap
import (
"os"
"github.com/getsentry/raven-go"
"github.com/mono83/slf/rays"
"github.com/mono83/slf/recievers/sentry"
"github.com/mono83/slf/recievers/statsd"
"github.com/mono83/slf/recievers/writer"
"github.com/mono83/slf/wd"
)
var version = ""
func GetVersion() string {
return version
}
func CreateLogger(statsdAddr string, sentryAddr string) (wd.Watchdog, error) {
wd.AddReceiver(writer.New(writer.Options{
Marker: false,
TimeFormat: "15:04:05.000",
}))
if statsdAddr != "" {
hostname, _ := os.Hostname()
statsdReceiver, err := statsd.NewReceiver(statsd.Config{
Address: statsdAddr,
Prefix: "ely.skinsystem." + hostname + ".app.",
FlushEvery: 1,
})
if err != nil {
return nil, err
}
wd.AddReceiver(statsdReceiver)
}
if sentryAddr != "" {
ravenClient, err := raven.New(sentryAddr)
if err != nil {
return nil, err
}
ravenClient.SetEnvironment("production")
ravenClient.SetDefaultLoggerName("sentry-watchdog-receiver")
programVersion := GetVersion()
if programVersion != "" {
raven.SetRelease(programVersion)
}
sentryReceiver, err := sentry.NewReceiverWithCustomRaven(ravenClient, &sentry.Config{
MinLevel: "warn",
})
if err != nil {
return nil, err
}
wd.AddReceiver(sentryReceiver)
}
return wd.New("", "").WithParams(rays.Host), nil
}

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,37 +0,0 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/elyby/chrly/bootstrap"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var RootCmd = &cobra.Command{
Use: "chrly",
Short: "Implementation of Minecraft skins system server",
Version: bootstrap.GetVersion(),
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := RootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
}
func initConfig() {
viper.AutomaticEnv()
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
}

View File

@@ -1,68 +0,0 @@
package cmd
import (
"fmt"
"log"
"github.com/elyby/chrly/auth"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/elyby/chrly/bootstrap"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/http"
)
var serveCmd = &cobra.Command{
Use: "serve",
Short: "Starts http handler for the skins system",
Run: func(cmd *cobra.Command, args []string) {
logger, err := bootstrap.CreateLogger(viper.GetString("statsd.addr"), viper.GetString("sentry.dsn"))
if err != nil {
log.Fatal(fmt.Printf("Cannot initialize logger: %v", err))
}
logger.Info("Logger successfully initialized")
storageFactory := db.StorageFactory{Config: viper.GetViper()}
logger.Info("Initializing skins repository")
skinsRepo, err := storageFactory.CreateFactory("redis").CreateSkinsRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err))
return
}
logger.Info("Skins repository successfully initialized")
logger.Info("Initializing capes repository")
capesRepo, err := storageFactory.CreateFactory("filesystem").CreateCapesRepository()
if err != nil {
logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err))
return
}
logger.Info("Capes repository successfully initialized")
cfg := &http.Config{
ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")),
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Logger: logger,
Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))},
}
if err := cfg.Run(); err != nil {
logger.Error(fmt.Sprintf("Error in main(): %v", err))
}
},
}
func init() {
RootCmd.AddCommand(serveCmd)
viper.SetDefault("server.host", "")
viper.SetDefault("server.port", 80)
viper.SetDefault("storage.redis.host", "localhost")
viper.SetDefault("storage.redis.port", 6379)
viper.SetDefault("storage.redis.poll", 10)
viper.SetDefault("storage.filesystem.basePath", "data")
viper.SetDefault("storage.filesystem.capesDirName", "capes")
}

View File

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

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
package db
type ParamRequired struct {
Param string
}
func (e ParamRequired) Error() string {
return "Required parameter not provided"
}
type SkinNotFoundError struct {
Who string
}
func (e SkinNotFoundError) Error() string {
return "Skin data not found."
}
type CapeNotFoundError struct {
Who string
}
func (e CapeNotFoundError) Error() string {
return "Cape file not found."
}

View File

@@ -1,34 +0,0 @@
package db
import (
"github.com/spf13/viper"
"github.com/elyby/chrly/interfaces"
)
type StorageFactory struct {
Config *viper.Viper
}
type RepositoriesCreator interface {
CreateSkinsRepository() (interfaces.SkinsRepository, error)
CreateCapesRepository() (interfaces.CapesRepository, error)
}
func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator {
switch backend {
case "redis":
return &RedisFactory{
Host: factory.Config.GetString("storage.redis.host"),
Port: factory.Config.GetInt("storage.redis.port"),
PoolSize: factory.Config.GetInt("storage.redis.poolSize"),
}
case "filesystem":
return &FilesystemFactory{
BasePath : factory.Config.GetString("storage.filesystem.basePath"),
CapesDirName: factory.Config.GetString("storage.filesystem.capesDirName"),
}
}
return nil
}

View File

@@ -1,59 +0,0 @@
package db
import (
"os"
"path"
"strings"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
)
type FilesystemFactory struct {
BasePath string
CapesDirName string
}
func (f FilesystemFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
panic("skins repository not supported for this storage type")
}
func (f FilesystemFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
if err := f.validateFactoryConfig(); err != nil {
return nil, err
}
return &filesStorage{path: path.Join(f.BasePath, f.CapesDirName)}, nil
}
func (f FilesystemFactory) validateFactoryConfig() error {
if f.BasePath == "" {
return &ParamRequired{"basePath"}
}
if f.CapesDirName == "" {
f.CapesDirName = "capes"
}
return nil
}
type filesStorage struct {
path string
}
func (repository *filesStorage) FindByUsername(username string) (*model.Cape, error) {
if username == "" {
return nil, &CapeNotFoundError{username}
}
capePath := path.Join(repository.path, strings.ToLower(username) + ".png")
file, err := os.Open(capePath)
if err != nil {
return nil, &CapeNotFoundError{username}
}
return &model.Cape{
File: file,
}, nil
}

View File

@@ -1,249 +0,0 @@
package db
import (
"bytes"
"compress/zlib"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"time"
"github.com/mediocregopher/radix.v2/pool"
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix.v2/util"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
)
type RedisFactory struct {
Host string
Port int
PoolSize int
connection *pool.Pool
}
// TODO: maybe we should manually return connection to the pool?
func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
connection, err := f.getConnection()
if err != nil {
return nil, err
}
return &redisDb{connection}, nil
}
func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
panic("capes repository not supported for this storage type")
}
func (f RedisFactory) getConnection() (*pool.Pool, error) {
if f.connection == nil {
if f.Host == "" {
return nil, &ParamRequired{"host"}
}
if f.Port == 0 {
return nil, &ParamRequired{"port"}
}
addr := fmt.Sprintf("%s:%d", f.Host, f.Port)
conn, err := pool.New("tcp", addr, f.PoolSize)
if err != nil {
return nil, err
}
f.connection = conn
go func() {
period := 5
for {
time.Sleep(time.Duration(period) * time.Second)
resp := f.connection.Cmd("PING")
if resp.Err == nil {
continue
}
log.Println("Redis not pinged. Try to reconnect")
conn, err := pool.New("tcp", addr, f.PoolSize)
if err != nil {
log.Printf("Cannot reconnect to redis: %v\n", err)
log.Printf("Waiting %d seconds to retry\n", period)
continue
}
f.connection = conn
log.Println("Reconnected")
}
}()
}
return f.connection, nil
}
type redisDb struct {
conn *pool.Pool
}
const accountIdToUsernameKey = "hash:username-to-account-id"
func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
return findByUsername(username, db.getConn())
}
func (db *redisDb) FindByUserId(id int) (*model.Skin, error) {
return findByUserId(id, db.getConn())
}
func (db *redisDb) Save(skin *model.Skin) error {
return save(skin, db.getConn())
}
func (db *redisDb) RemoveByUserId(id int) error {
return removeByUserId(id, db.getConn())
}
func (db *redisDb) RemoveByUsername(username string) error {
return removeByUsername(username, db.getConn())
}
func (db *redisDb) getConn() util.Cmder {
conn, _ := db.conn.Get()
return conn
}
func findByUsername(username string, conn util.Cmder) (*model.Skin, error) {
if username == "" {
return nil, &SkinNotFoundError{username}
}
redisKey := buildUsernameKey(username)
response := conn.Cmd("GET", redisKey)
if response.IsType(redis.Nil) {
return nil, &SkinNotFoundError{username}
}
encodedResult, err := response.Bytes()
if err != nil {
return nil, err
}
result, err := zlibDecode(encodedResult)
if err != nil {
return nil, err
}
var skin *model.Skin
err = json.Unmarshal(result, &skin)
if err != nil {
return nil, err
}
skin.OldUsername = skin.Username
return skin, nil
}
func findByUserId(id int, conn util.Cmder) (*model.Skin, error) {
response := conn.Cmd("HGET", accountIdToUsernameKey, id)
if response.IsType(redis.Nil) {
return nil, &SkinNotFoundError{"unknown"}
}
username, _ := response.Str()
return findByUsername(username, conn)
}
func removeByUserId(id int, conn util.Cmder) error {
record, err := findByUserId(id, conn)
if err != nil {
if _, ok := err.(*SkinNotFoundError); !ok {
return err
}
}
conn.Cmd("MULTI")
conn.Cmd("HDEL", accountIdToUsernameKey, id)
if record != nil {
conn.Cmd("DEL", buildUsernameKey(record.Username))
}
conn.Cmd("EXEC")
return nil
}
func removeByUsername(username string, conn util.Cmder) error {
record, err := findByUsername(username, conn)
if err != nil {
if _, ok := err.(*SkinNotFoundError); !ok {
return err
}
}
conn.Cmd("MULTI")
conn.Cmd("DEL", buildUsernameKey(record.Username))
if record != nil {
conn.Cmd("HDEL", accountIdToUsernameKey, record.UserId)
}
conn.Cmd("EXEC")
return nil
}
func save(skin *model.Skin, conn util.Cmder) error {
conn.Cmd("MULTI")
// If user has changed username, then we must delete his old username record
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
conn.Cmd("DEL", buildUsernameKey(skin.OldUsername))
}
// If this is a new record or if the user has changed username, we set the value in the hash table
if skin.OldUsername != "" || skin.OldUsername != skin.Username {
conn.Cmd("HSET", accountIdToUsernameKey, skin.UserId, skin.Username)
}
str, _ := json.Marshal(skin)
conn.Cmd("SET", buildUsernameKey(skin.Username), zlibEncode(str))
conn.Cmd("EXEC")
skin.OldUsername = skin.Username
return nil
}
func buildUsernameKey(username string) string {
return "username:" + strings.ToLower(username)
}
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
writer.Write(str)
writer.Close()
return buff.Bytes()
}
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, readError := zlib.NewReader(buff)
if readError != nil {
return nil, readError
}
resultBuffer := new(bytes.Buffer)
io.Copy(resultBuffer, reader)
reader.Close()
return resultBuffer.Bytes(), nil
}

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

View File

@@ -1,27 +0,0 @@
# This file can be used to run application in the production environment.
# Copy it into the docker-compose.yml:
# > cp docker-compose.prod.yml docker-compose.yml
# And then run it:
# > docker-compose up -d
# Service will be listened at the http://localhost
version: '2'
services:
app:
image: elyby/chrly
hostname: chrly0
restart: always
links:
- redis
volumes:
- ./data/capes:/data/capes
ports:
- "80:80"
environment:
CHRLY_SECRET: replace_this_value_in_production
redis:
image: redis:4.0-32bit # 32-bit version is recommended to spare some memory
restart: always
volumes:
- ./data/redis:/data

View File

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

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=

View File

@@ -1,261 +0,0 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/interfaces"
"github.com/elyby/chrly/model"
"github.com/gorilla/mux"
"github.com/mono83/slf/wd"
"github.com/thedevsaddam/govalidator"
)
//noinspection GoSnakeCaseUsage
const UUID_ANY = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
var regexUuidAny = regexp.MustCompile(UUID_ANY)
func init() {
govalidator.AddCustomRule("skinUploadingNotAvailable", func(field string, rule string, message string, value interface{}) error {
if message == "" {
message = "Skin uploading is temporary unavailable"
}
return errors.New(message)
})
// Add ability to validate any possible uuid form
govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error {
str := value.(string)
if !regexUuidAny.MatchString(str) {
if message == "" {
message = fmt.Sprintf("The %s field must contain valid UUID", field)
}
return errors.New(message)
}
return nil
})
}
func (cfg *Config) PostSkin(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.post.request", 1)
validationErrors := validatePostSkinRequest(req)
if validationErrors != nil {
cfg.Logger.IncCounter("api.skins.post.validation_failed", 1)
apiBadRequest(resp, validationErrors)
return
}
identityId, _ := strconv.Atoi(req.Form.Get("identityId"))
username := req.Form.Get("username")
record, err := findIdentity(cfg.SkinsRepo, identityId, username)
if err != nil {
cfg.Logger.Error("Error on requesting a skin from the repository: :err", wd.ErrParam(err))
apiServerError(resp)
return
}
skinId, _ := strconv.Atoi(req.Form.Get("skinId"))
is18, _ := strconv.ParseBool(req.Form.Get("is1_8"))
isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim"))
record.Uuid = req.Form.Get("uuid")
record.SkinId = skinId
record.Hash = req.Form.Get("hash")
record.Is1_8 = is18
record.IsSlim = isSlim
record.Url = req.Form.Get("url")
record.MojangTextures = req.Form.Get("mojangTextures")
record.MojangSignature = req.Form.Get("mojangSignature")
err = cfg.SkinsRepo.Save(record)
if err != nil {
cfg.Logger.Error("Unable to save record to the repository: :err", wd.ErrParam(err))
apiServerError(resp)
return
}
cfg.Logger.IncCounter("api.skins.post.success", 1)
resp.WriteHeader(http.StatusCreated)
}
func (cfg *Config) DeleteSkinByUserId(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.delete.request", 1)
id, _ := strconv.Atoi(mux.Vars(req)["id"])
skin, err := cfg.SkinsRepo.FindByUserId(id)
if err != nil {
cfg.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested user id")
return
}
cfg.deleteSkin(skin, resp)
}
func (cfg *Config) DeleteSkinByUsername(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("api.skins.delete.request", 1)
username := mux.Vars(req)["username"]
skin, err := cfg.SkinsRepo.FindByUsername(username)
if err != nil {
cfg.Logger.IncCounter("api.skins.delete.not_found", 1)
apiNotFound(resp, "Cannot find record for requested username")
return
}
cfg.deleteSkin(skin, resp)
}
func (cfg *Config) Authenticate(handler http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
cfg.Logger.IncCounter("authentication.challenge", 1)
err := cfg.Auth.Check(req)
if err != nil {
if _, ok := err.(*auth.Unauthorized); ok {
cfg.Logger.IncCounter("authentication.failed", 1)
apiForbidden(resp, err.Error())
} else {
cfg.Logger.Error("Unknown error on validating api request: :err", wd.ErrParam(err))
apiServerError(resp)
}
return
}
cfg.Logger.IncCounter("authentication.success", 1)
handler.ServeHTTP(resp, req)
})
}
func (cfg *Config) deleteSkin(skin *model.Skin, resp http.ResponseWriter) {
err := cfg.SkinsRepo.RemoveByUserId(skin.UserId)
if err != nil {
cfg.Logger.Error("Cannot delete skin by error: :err", wd.ErrParam(err))
apiServerError(resp)
return
}
cfg.Logger.IncCounter("api.skins.delete.success", 1)
resp.WriteHeader(http.StatusNoContent)
}
func validatePostSkinRequest(request *http.Request) map[string][]string {
const maxMultipartMemory int64 = 32 << 20
const oneOfSkinOrUrlMessage = "One of url or skin should be provided, but not both"
request.ParseMultipartForm(maxMultipartMemory)
validationRules := govalidator.MapData{
"identityId": {"required", "numeric", "min:1"},
"username": {"required"},
"uuid": {"required", "uuid_any"},
"skinId": {"required", "numeric", "min:1"},
"url": {"url"},
"file:skin": {"ext:png", "size:24576", "mime:image/png"},
"hash": {},
"is1_8": {"bool"},
"isSlim": {"bool"},
}
shouldAppendSkinRequiredError := false
url := request.Form.Get("url")
_, _, skinErr := request.FormFile("skin")
if (url != "" && skinErr == nil) || (url == "" && skinErr != nil) {
shouldAppendSkinRequiredError = true
} else if skinErr == nil {
validationRules["file:skin"] = append(validationRules["file:skin"], "skinUploadingNotAvailable")
} else if url != "" {
validationRules["hash"] = append(validationRules["hash"], "required")
validationRules["is1_8"] = append(validationRules["is1_8"], "required")
validationRules["isSlim"] = append(validationRules["isSlim"], "required")
}
mojangTextures := request.Form.Get("mojangTextures")
if mojangTextures != "" {
validationRules["mojangSignature"] = []string{"required"}
}
validator := govalidator.New(govalidator.Options{
Request: request,
Rules: validationRules,
RequiredDefault: false,
FormSize: maxMultipartMemory,
})
validationResults := validator.Validate()
if shouldAppendSkinRequiredError {
validationResults["url"] = append(validationResults["url"], oneOfSkinOrUrlMessage)
validationResults["skin"] = append(validationResults["skin"], oneOfSkinOrUrlMessage)
}
if len(validationResults) != 0 {
return validationResults
}
return nil
}
func findIdentity(repo interfaces.SkinsRepository, identityId int, username string) (*model.Skin, error) {
var record *model.Skin
record, err := repo.FindByUserId(identityId)
if err != nil {
if _, isSkinNotFound := err.(*db.SkinNotFoundError); !isSkinNotFound {
return nil, err
}
record, err = repo.FindByUsername(username)
if err == nil {
repo.RemoveByUsername(username)
record.UserId = identityId
} else {
record = &model.Skin{
UserId: identityId,
Username: username,
}
}
} else if record.Username != username {
repo.RemoveByUserId(identityId)
record.Username = username
}
return record, nil
}
func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) {
resp.WriteHeader(http.StatusBadRequest)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"errors": errorsPerField,
})
resp.Write(result)
}
func apiForbidden(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusForbidden)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal(map[string]interface{}{
"error": reason,
})
resp.Write(result)
}
func apiNotFound(resp http.ResponseWriter, reason string) {
resp.WriteHeader(http.StatusNotFound)
resp.Header().Set("Content-Type", "application/json")
result, _ := json.Marshal([]interface{}{
reason,
})
resp.Write(result)
}
func apiServerError(resp http.ResponseWriter) {
resp.WriteHeader(http.StatusInternalServerError)
}

View File

@@ -1,501 +0,0 @@
package http
import (
"bytes"
"encoding/base64"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/elyby/chrly/auth"
"github.com/elyby/chrly/db"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
)
func TestConfig_PostSkin_Valid(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
form := url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_ChangedIdentityId(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.UserId = 2
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"2"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_ChangedUsername(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("changed_username", false)
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"1"},
"username": {"changed_username"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_CompletelyNewIdentity(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
resultModel := createSkinModel("mock_user", false)
resultModel.SkinId = 5
resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a"
resultModel.Url = "http://ely.by/minecraft/skins/default.png"
resultModel.MojangTextures = ""
resultModel.MojangSignature = ""
form := url.Values{
"identityId": {"1"},
"username": {"mock_user"},
"uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"},
"skinId": {"5"},
"hash": {"94a457d92a61460cb9cb5d6f29732d2a"},
"is1_8": {"0"},
"isSlim": {"0"},
"url": {"http://ely.by/minecraft/skins/default.png"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{"unknown"})
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{"mock_user"})
mocks.Skins.EXPECT().Save(resultModel).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(201, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_PostSkin_UploadSkin(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("skin", "char.png")
part.Write(loadSkinFile())
_ = writer.WriteField("identityId", "1")
_ = writer.WriteField("username", "mock_user")
_ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3")
_ = writer.WriteField("skinId", "5")
err := writer.Close()
if err != nil {
panic(err)
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", body)
req.Header.Add("Content-Type", writer.FormDataContentType())
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"skin": [
"Skin uploading is temporary unavailable"
]
}
}`, string(response))
}
func TestConfig_PostSkin_RequiredFields(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
form := url.Values{
"mojangTextures": {"someBase64EncodedString"},
}
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(400, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"errors": {
"identityId": [
"The identityId field is required",
"The identityId field must be numeric",
"The identityId field must be minimum 1 char"
],
"skinId": [
"The skinId field is required",
"The skinId field must be numeric",
"The skinId field must be minimum 1 char"
],
"username": [
"The username field is required"
],
"uuid": [
"The uuid field is required",
"The uuid field must contain valid UUID"
],
"url": [
"One of url or skin should be provided, but not both"
],
"skin": [
"One of url or skin should be provided, but not both"
],
"mojangSignature": [
"The mojangSignature field is required"
]
}
}`, string(response))
}
func TestConfig_PostSkin_Unauthorized(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", nil)
req.Header.Add("Authorization", "Bearer invalid.jwt.token")
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"Cannot parse passed JWT token"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "Cannot parse passed JWT token"
}`, string(response))
}
func TestConfig_DeleteSkinByUserId_Success(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:1", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_DeleteSkinByUserId_NotFound(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:2", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested user id"
]`, string(response))
}
func TestConfig_DeleteSkinByUsername_Success(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil)
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Empty(response)
}
func TestConfig_DeleteSkinByUsername_NotFound(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user_2", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil)
mocks.Skins.EXPECT().FindByUsername("mock_user_2").Return(nil, &db.SkinNotFoundError{"mock_user_2"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.success", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1))
mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1))
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(404, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`[
"Cannot find record for requested username"
]`, string(response))
}
func TestConfig_Authenticate_SignatureKeyNotSet(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
req := httptest.NewRequest("POST", "http://localhost", nil)
w := httptest.NewRecorder()
mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"signing key not available"})
mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1))
mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1))
res := config.Authenticate(http.HandlerFunc(func (resp http.ResponseWriter, req *http.Request) {}))
res.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
assert.Equal(403, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"error": "signing key not available"
}`, string(response))
}
// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png
var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==")
func loadSkinFile() []byte {
result := make([]byte, 92)
_, err := base64.StdEncoding.Decode(result, OnePxPng)
if err != nil {
panic(err)
}
return result
}

View File

@@ -1,38 +0,0 @@
package http
import (
"io"
"net/http"
"github.com/gorilla/mux"
)
func (cfg *Config) Cape(response http.ResponseWriter, request *http.Request) {
if mux.Vars(request)["converted"] == "" {
cfg.Logger.IncCounter("capes.request", 1)
}
username := parseUsername(mux.Vars(request)["username"])
rec, err := cfg.CapesRepo.FindByUsername(username)
if err != nil {
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
return
}
request.Header.Set("Content-Type", "image/png")
io.Copy(response, rec.File)
}
func (cfg *Config) CapeGET(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("capes.get_request", 1)
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
cfg.Cape(response, request)
}

View File

@@ -1,138 +0,0 @@
package http
import (
"bytes"
"image"
"image/png"
"io/ioutil"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/model"
)
func TestConfig_Cape(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
cape := createCape()
mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{
File: bytes.NewReader(cape),
}, nil)
mocks.Log.EXPECT().IncCounter("capes.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/mocked_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
assert.Equal(cape, responseData)
assert.Equal("image/png", resp.Header.Get("Content-Type"))
}
func TestConfig_Cape2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("capes.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location"))
}
func TestConfig_CapeGET(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
cape := createCape()
mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{
File: bytes.NewReader(cape),
}, nil)
mocks.Log.EXPECT().IncCounter("capes.request", int64(1)).Times(0)
mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=mocked_username", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
responseData, _ := ioutil.ReadAll(resp.Body)
assert.Equal(cape, responseData)
assert.Equal("image/png", resp.Header.Get("Content-Type"))
}
func TestConfig_CapeGET2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("capes.request", int64(1)).Times(0)
mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location"))
}
func TestConfig_CapeGET3(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/?name=notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skinsystem.ely.by/cloaks?name=notch", resp.Header.Get("Location"))
}
// Cape md5: 424ff79dce9940af89c28ad80de8aaad
func createCape() []byte {
img := image.NewAlpha(image.Rect(0, 0, 64, 32))
writer := &bytes.Buffer{}
png.Encode(writer, img)
pngBytes, _ := ioutil.ReadAll(writer)
return pngBytes
}

View File

@@ -1,85 +0,0 @@
package http
import (
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/mono83/slf/wd"
"github.com/elyby/chrly/interfaces"
)
type Config struct {
ListenSpec string
SkinsRepo interfaces.SkinsRepository
CapesRepo interfaces.CapesRepository
Logger wd.Watchdog
Auth interfaces.AuthChecker
}
func (cfg *Config) Run() error {
cfg.Logger.Info(fmt.Sprintf("Starting, HTTP on: %s\n", cfg.ListenSpec))
listener, err := net.Listen("tcp", cfg.ListenSpec)
if err != nil {
return err
}
server := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: cfg.CreateHandler(),
}
go server.Serve(listener)
s := waitForSignal()
cfg.Logger.Info(fmt.Sprintf("Got signal: %v, exiting.", s))
return nil
}
func (cfg *Config) CreateHandler() http.Handler {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", cfg.Skin).Methods("GET")
router.HandleFunc("/cloaks/{username}", cfg.Cape).Methods("GET").Name("cloaks")
router.HandleFunc("/textures/{username}", cfg.Textures).Methods("GET")
router.HandleFunc("/textures/signed/{username}", cfg.SignedTextures).Methods("GET")
// Legacy
router.HandleFunc("/skins", cfg.SkinGET).Methods("GET")
router.HandleFunc("/cloaks", cfg.CapeGET).Methods("GET")
// API
router.Handle("/api/skins", cfg.Authenticate(http.HandlerFunc(cfg.PostSkin))).Methods("POST")
router.Handle("/api/skins/id:{id:[0-9]+}", cfg.Authenticate(http.HandlerFunc(cfg.DeleteSkinByUserId))).Methods("DELETE")
router.Handle("/api/skins/{username}", cfg.Authenticate(http.HandlerFunc(cfg.DeleteSkinByUsername))).Methods("DELETE")
// 404
router.NotFoundHandler = http.HandlerFunc(cfg.NotFound)
return router
}
func parseUsername(username string) string {
const suffix = ".png"
if strings.HasSuffix(username, suffix) {
username = strings.TrimSuffix(username, suffix)
}
return username
}
func waitForSignal() os.Signal {
ch := make(chan os.Signal)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
return <-ch
}

View File

@@ -1,46 +0,0 @@
package http
import (
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/interfaces/mock_interfaces"
"github.com/elyby/chrly/interfaces/mock_wd"
)
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")
}
type mocks struct {
Skins *mock_interfaces.MockSkinsRepository
Capes *mock_interfaces.MockCapesRepository
Auth *mock_interfaces.MockAuthChecker
Log *mock_wd.MockWatchdog
}
func setupMocks(ctrl *gomock.Controller) (
*Config,
*mocks,
) {
skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl)
capesRepo := mock_interfaces.NewMockCapesRepository(ctrl)
authChecker := mock_interfaces.NewMockAuthChecker(ctrl)
wd := mock_wd.NewMockWatchdog(ctrl)
return &Config{
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Auth: authChecker,
Logger: wd,
}, &mocks{
Skins: skinsRepo,
Capes: capesRepo,
Auth: authChecker,
Log: wd,
}
}

View File

@@ -1,17 +0,0 @@
package http
import (
"encoding/json"
"net/http"
)
func (cfg *Config) NotFound(response http.ResponseWriter, request *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)
}

View File

@@ -1,27 +0,0 @@
package http
import (
"io/ioutil"
"net/http/httptest"
"testing"
testify "github.com/stretchr/testify/assert"
)
func TestConfig_NotFound(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(404, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"status": "404",
"message": "Not Found"
}`, string(response))
}

View File

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

View File

@@ -1,71 +0,0 @@
package http
import (
"io/ioutil"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
)
func TestConfig_SignedTextures(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_user",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "chrly",
"value": "how do you tame a horse in Minecraft?"
}
]
}`, string(response))
}
func TestConfig_SignedTextures2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{})
mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
}

View File

@@ -1,36 +0,0 @@
package http
import (
"net/http"
"github.com/gorilla/mux"
)
func (cfg *Config) Skin(response http.ResponseWriter, request *http.Request) {
if mux.Vars(request)["converted"] == "" {
cfg.Logger.IncCounter("skins.request", 1)
}
username := parseUsername(mux.Vars(request)["username"])
rec, err := cfg.SkinsRepo.FindByUsername(username)
if err != nil || rec.SkinId == 0 {
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301)
return
}
http.Redirect(response, request, rec.Url, 301)
}
func (cfg *Config) SkinGET(response http.ResponseWriter, request *http.Request) {
cfg.Logger.IncCounter("skins.get_request", 1)
username := request.URL.Query().Get("name")
if username == "" {
response.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(request)["username"] = username
mux.Vars(request)["converted"] = "1"
cfg.Skin(response, request)
}

View File

@@ -1,125 +0,0 @@
package http
import (
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"github.com/elyby/chrly/db"
"github.com/elyby/chrly/model"
)
func TestConfig_Skin(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Log.EXPECT().IncCounter("skins.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location"))
}
func TestConfig_Skin2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("skins.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location"))
}
func TestConfig_SkinGET(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil)
mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1))
mocks.Log.EXPECT().IncCounter("skins.request", int64(1)).Times(0)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location"))
}
func TestConfig_SkinGET2(t *testing.T) {
assert := testify.New(t)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
config, mocks := setupMocks(ctrl)
mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"})
mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1))
mocks.Log.EXPECT().IncCounter("skins.request", int64(1)).Times(0)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=notch", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location"))
}
func TestConfig_SkinGET3(t *testing.T) {
assert := testify.New(t)
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/?name=notch", nil)
w := httptest.NewRecorder()
(&Config{}).CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(301, resp.StatusCode)
assert.Equal("http://skinsystem.ely.by/skins?name=notch", resp.Header.Get("Location"))
}
func createSkinModel(username string, isSlim bool) *model.Skin {
return &model.Skin{
UserId: 1,
Username: username,
Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3",
SkinId: 1,
Hash: "55d2a8848764f5ff04012cdb093458bd",
Url: "http://ely.by/minecraft/skins/skin.png",
MojangTextures: "mocked textures base64",
MojangSignature: "mocked signature",
IsSlim: isSlim,
}
}

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
package interfaces
import "net/http"
type AuthChecker interface {
Check(req *http.Request) error
}

View File

@@ -1,45 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: interfaces/auth.go
package mock_interfaces
import (
gomock "github.com/golang/mock/gomock"
http "net/http"
reflect "reflect"
)
// MockAuthChecker is a mock of AuthChecker interface
type MockAuthChecker struct {
ctrl *gomock.Controller
recorder *MockAuthCheckerMockRecorder
}
// MockAuthCheckerMockRecorder is the mock recorder for MockAuthChecker
type MockAuthCheckerMockRecorder struct {
mock *MockAuthChecker
}
// NewMockAuthChecker creates a new mock instance
func NewMockAuthChecker(ctrl *gomock.Controller) *MockAuthChecker {
mock := &MockAuthChecker{ctrl: ctrl}
mock.recorder = &MockAuthCheckerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockAuthChecker) EXPECT() *MockAuthCheckerMockRecorder {
return _m.recorder
}
// Check mocks base method
func (_m *MockAuthChecker) Check(req *http.Request) error {
ret := _m.ctrl.Call(_m, "Check", req)
ret0, _ := ret[0].(error)
return ret0
}
// Check indicates an expected call of Check
func (_mr *MockAuthCheckerMockRecorder) Check(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Check", reflect.TypeOf((*MockAuthChecker)(nil).Check), arg0)
}

View File

@@ -1,131 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: interfaces/repositories.go
package mock_interfaces
import (
model "github.com/elyby/chrly/model"
gomock "github.com/golang/mock/gomock"
reflect "reflect"
)
// MockSkinsRepository is a mock of SkinsRepository interface
type MockSkinsRepository struct {
ctrl *gomock.Controller
recorder *MockSkinsRepositoryMockRecorder
}
// MockSkinsRepositoryMockRecorder is the mock recorder for MockSkinsRepository
type MockSkinsRepositoryMockRecorder struct {
mock *MockSkinsRepository
}
// NewMockSkinsRepository creates a new mock instance
func NewMockSkinsRepository(ctrl *gomock.Controller) *MockSkinsRepository {
mock := &MockSkinsRepository{ctrl: ctrl}
mock.recorder = &MockSkinsRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockSkinsRepository) EXPECT() *MockSkinsRepositoryMockRecorder {
return _m.recorder
}
// FindByUsername mocks base method
func (_m *MockSkinsRepository) FindByUsername(username string) (*model.Skin, error) {
ret := _m.ctrl.Call(_m, "FindByUsername", username)
ret0, _ := ret[0].(*model.Skin)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByUsername indicates an expected call of FindByUsername
func (_mr *MockSkinsRepositoryMockRecorder) FindByUsername(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "FindByUsername", reflect.TypeOf((*MockSkinsRepository)(nil).FindByUsername), arg0)
}
// FindByUserId mocks base method
func (_m *MockSkinsRepository) FindByUserId(id int) (*model.Skin, error) {
ret := _m.ctrl.Call(_m, "FindByUserId", id)
ret0, _ := ret[0].(*model.Skin)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByUserId indicates an expected call of FindByUserId
func (_mr *MockSkinsRepositoryMockRecorder) FindByUserId(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "FindByUserId", reflect.TypeOf((*MockSkinsRepository)(nil).FindByUserId), arg0)
}
// Save mocks base method
func (_m *MockSkinsRepository) Save(skin *model.Skin) error {
ret := _m.ctrl.Call(_m, "Save", skin)
ret0, _ := ret[0].(error)
return ret0
}
// Save indicates an expected call of Save
func (_mr *MockSkinsRepositoryMockRecorder) Save(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Save", reflect.TypeOf((*MockSkinsRepository)(nil).Save), arg0)
}
// RemoveByUserId mocks base method
func (_m *MockSkinsRepository) RemoveByUserId(id int) error {
ret := _m.ctrl.Call(_m, "RemoveByUserId", id)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveByUserId indicates an expected call of RemoveByUserId
func (_mr *MockSkinsRepositoryMockRecorder) RemoveByUserId(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RemoveByUserId", reflect.TypeOf((*MockSkinsRepository)(nil).RemoveByUserId), arg0)
}
// RemoveByUsername mocks base method
func (_m *MockSkinsRepository) RemoveByUsername(username string) error {
ret := _m.ctrl.Call(_m, "RemoveByUsername", username)
ret0, _ := ret[0].(error)
return ret0
}
// RemoveByUsername indicates an expected call of RemoveByUsername
func (_mr *MockSkinsRepositoryMockRecorder) RemoveByUsername(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RemoveByUsername", reflect.TypeOf((*MockSkinsRepository)(nil).RemoveByUsername), arg0)
}
// MockCapesRepository is a mock of CapesRepository interface
type MockCapesRepository struct {
ctrl *gomock.Controller
recorder *MockCapesRepositoryMockRecorder
}
// MockCapesRepositoryMockRecorder is the mock recorder for MockCapesRepository
type MockCapesRepositoryMockRecorder struct {
mock *MockCapesRepository
}
// NewMockCapesRepository creates a new mock instance
func NewMockCapesRepository(ctrl *gomock.Controller) *MockCapesRepository {
mock := &MockCapesRepository{ctrl: ctrl}
mock.recorder = &MockCapesRepositoryMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockCapesRepository) EXPECT() *MockCapesRepositoryMockRecorder {
return _m.recorder
}
// FindByUsername mocks base method
func (_m *MockCapesRepository) FindByUsername(username string) (*model.Cape, error) {
ret := _m.ctrl.Call(_m, "FindByUsername", username)
ret0, _ := ret[0].(*model.Cape)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// FindByUsername indicates an expected call of FindByUsername
func (_mr *MockCapesRepositoryMockRecorder) FindByUsername(arg0 interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "FindByUsername", reflect.TypeOf((*MockCapesRepository)(nil).FindByUsername), arg0)
}

View File

@@ -1,218 +0,0 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/mono83/slf/wd (interfaces: Watchdog)
package mock_wd
import (
gomock "github.com/golang/mock/gomock"
slf "github.com/mono83/slf"
wd "github.com/mono83/slf/wd"
reflect "reflect"
time "time"
)
// MockWatchdog is a mock of Watchdog interface
type MockWatchdog struct {
ctrl *gomock.Controller
recorder *MockWatchdogMockRecorder
}
// MockWatchdogMockRecorder is the mock recorder for MockWatchdog
type MockWatchdogMockRecorder struct {
mock *MockWatchdog
}
// NewMockWatchdog creates a new mock instance
func NewMockWatchdog(ctrl *gomock.Controller) *MockWatchdog {
mock := &MockWatchdog{ctrl: ctrl}
mock.recorder = &MockWatchdogMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use
func (_m *MockWatchdog) EXPECT() *MockWatchdogMockRecorder {
return _m.recorder
}
// Alert mocks base method
func (_m *MockWatchdog) Alert(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Alert", _s...)
}
// Alert indicates an expected call of Alert
func (_mr *MockWatchdogMockRecorder) Alert(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Alert", reflect.TypeOf((*MockWatchdog)(nil).Alert), _s...)
}
// Debug mocks base method
func (_m *MockWatchdog) Debug(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Debug", _s...)
}
// Debug indicates an expected call of Debug
func (_mr *MockWatchdogMockRecorder) Debug(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Debug", reflect.TypeOf((*MockWatchdog)(nil).Debug), _s...)
}
// Emergency mocks base method
func (_m *MockWatchdog) Emergency(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Emergency", _s...)
}
// Emergency indicates an expected call of Emergency
func (_mr *MockWatchdogMockRecorder) Emergency(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Emergency", reflect.TypeOf((*MockWatchdog)(nil).Emergency), _s...)
}
// Error mocks base method
func (_m *MockWatchdog) Error(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Error", _s...)
}
// Error indicates an expected call of Error
func (_mr *MockWatchdogMockRecorder) Error(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Error", reflect.TypeOf((*MockWatchdog)(nil).Error), _s...)
}
// IncCounter mocks base method
func (_m *MockWatchdog) IncCounter(_param0 string, _param1 int64, _param2 ...slf.Param) {
_s := []interface{}{_param0, _param1}
for _, _x := range _param2 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "IncCounter", _s...)
}
// IncCounter indicates an expected call of IncCounter
func (_mr *MockWatchdogMockRecorder) IncCounter(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0, arg1}, arg2...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "IncCounter", reflect.TypeOf((*MockWatchdog)(nil).IncCounter), _s...)
}
// Info mocks base method
func (_m *MockWatchdog) Info(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Info", _s...)
}
// Info indicates an expected call of Info
func (_mr *MockWatchdogMockRecorder) Info(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Info", reflect.TypeOf((*MockWatchdog)(nil).Info), _s...)
}
// RecordTimer mocks base method
func (_m *MockWatchdog) RecordTimer(_param0 string, _param1 time.Duration, _param2 ...slf.Param) {
_s := []interface{}{_param0, _param1}
for _, _x := range _param2 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "RecordTimer", _s...)
}
// RecordTimer indicates an expected call of RecordTimer
func (_mr *MockWatchdogMockRecorder) RecordTimer(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0, arg1}, arg2...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "RecordTimer", reflect.TypeOf((*MockWatchdog)(nil).RecordTimer), _s...)
}
// Timer mocks base method
func (_m *MockWatchdog) Timer(_param0 string, _param1 ...slf.Param) slf.Timer {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
ret := _m.ctrl.Call(_m, "Timer", _s...)
ret0, _ := ret[0].(slf.Timer)
return ret0
}
// Timer indicates an expected call of Timer
func (_mr *MockWatchdogMockRecorder) Timer(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Timer", reflect.TypeOf((*MockWatchdog)(nil).Timer), _s...)
}
// Trace mocks base method
func (_m *MockWatchdog) Trace(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Trace", _s...)
}
// Trace indicates an expected call of Trace
func (_mr *MockWatchdogMockRecorder) Trace(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Trace", reflect.TypeOf((*MockWatchdog)(nil).Trace), _s...)
}
// UpdateGauge mocks base method
func (_m *MockWatchdog) UpdateGauge(_param0 string, _param1 int64, _param2 ...slf.Param) {
_s := []interface{}{_param0, _param1}
for _, _x := range _param2 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "UpdateGauge", _s...)
}
// UpdateGauge indicates an expected call of UpdateGauge
func (_mr *MockWatchdogMockRecorder) UpdateGauge(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0, arg1}, arg2...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "UpdateGauge", reflect.TypeOf((*MockWatchdog)(nil).UpdateGauge), _s...)
}
// Warning mocks base method
func (_m *MockWatchdog) Warning(_param0 string, _param1 ...slf.Param) {
_s := []interface{}{_param0}
for _, _x := range _param1 {
_s = append(_s, _x)
}
_m.ctrl.Call(_m, "Warning", _s...)
}
// Warning indicates an expected call of Warning
func (_mr *MockWatchdogMockRecorder) Warning(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
_s := append([]interface{}{arg0}, arg1...)
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "Warning", reflect.TypeOf((*MockWatchdog)(nil).Warning), _s...)
}
// WithParams mocks base method
func (_m *MockWatchdog) WithParams(_param0 ...slf.Param) wd.Watchdog {
_s := []interface{}{}
for _, _x := range _param0 {
_s = append(_s, _x)
}
ret := _m.ctrl.Call(_m, "WithParams", _s...)
ret0, _ := ret[0].(wd.Watchdog)
return ret0
}
// WithParams indicates an expected call of WithParams
func (_mr *MockWatchdogMockRecorder) WithParams(arg0 ...interface{}) *gomock.Call {
return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "WithParams", reflect.TypeOf((*MockWatchdog)(nil).WithParams), arg0...)
}

View File

@@ -1,17 +0,0 @@
package interfaces
import (
"github.com/elyby/chrly/model"
)
type SkinsRepository interface {
FindByUsername(username string) (*model.Skin, error)
FindByUserId(id int) (*model.Skin, error)
Save(skin *model.Skin) error
RemoveByUserId(id int) error
RemoveByUsername(username string) error
}
type CapesRepository interface {
FindByUsername(username string) (*model.Cape, error)
}

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

View File

@@ -2,19 +2,28 @@ package cmd
import (
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
"github.com/elyby/chrly/bootstrap"
"runtime"
"ely.by/chrly/internal/version"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show the Chrly version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Version: %s\n", bootstrap.GetVersion())
fmt.Printf("Go version: %s\n", runtime.Version())
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)
},
}

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
}

Some files were not shown because too many files have changed in this diff Show More