diff --git a/.dockerignore b/.dockerignore index eab7932..b9546b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,2 @@ -# Игнорим данные, т.к. они не нужны для внутреннего содержимого этого контейнера data - -# Vendor так же не нужен vendor diff --git a/.gitignore b/.gitignore index 55118de..efa479e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,5 @@ -# IDEA -/.idea - -# Docker Compose file -/docker-compose.yml -/docker-compose.override.yml - -# vendor -/vendor - -# Cover output +.idea +docker-compose.yml +docker-compose.override.yml +vendor .cover - -# Local config -/config.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 4c4aec3..0000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,96 +0,0 @@ -# Предполагается, что между работой "build docker container" и этапом push -# построенные docker images остаются статичными и никуда не пропадают -# -# В противном случае их нужно после каждого этапа билда пушить в registry - -stages: - - test - - build - - build_docker_image - - push - - cleanup - -variables: - CONTAINER_IMAGE: registry.ely.by/elyby/skinsystem - -.golang_template: &setup_go_environment - image: golang:1.9.0-alpine3.6 - before_script: - - apk add --no-cache git - - mkdir -p $GOPATH/src/$CI_PROJECT_NAMESPACE - - cp -r $(pwd) $GOPATH/src/$CI_PROJECT_PATH - - cd $GOPATH/src/$CI_PROJECT_PATH - - go get -u github.com/golang/dep/cmd/dep - - $GOPATH/bin/dep ensure - -.docker_template: &setup_docker_environment - image: docker:latest - before_script: - - docker login -u gitlab-ci -p $CI_JOB_TOKEN registry.ely.by - - export TEMP_IMAGE_NAME="$CONTAINER_IMAGE:$CI_PIPELINE_ID" - -test: - <<: *setup_go_environment - stage: test - script: - - ./script/coverage - -build executable: - <<: *setup_go_environment - stage: build - script: - - export VERSION="${CI_COMMIT_TAG:-dev-$CI_COMMIT_REF_NAME-${CI_COMMIT_SHA:0:8}+build-$CI_JOB_ID}" - - > - env GOOS=linux - go build - -o $CI_PROJECT_DIR/minecraft-skinsystem - -ldflags "-X ${CI_PROJECT_PATH}/bootstrap.version=${VERSION}" - main.go - artifacts: - name: "${CI_JOB_STAGE} executable" - paths: - - $CI_PROJECT_DIR/minecraft-skinsystem - expire_in: 1 day - -build docker image: - <<: *setup_docker_environment - stage: build_docker_image - script: - - docker build -t $TEMP_IMAGE_NAME -f docker/Dockerfile . - only: - - tags - - develop - -push dev: - <<: *setup_docker_environment - stage: push - variables: - GIT_STRATEGY: none - script: - - export IMAGE_NAME="$CONTAINER_IMAGE:dev" - - docker tag $TEMP_IMAGE_NAME $IMAGE_NAME - - docker push $IMAGE_NAME - only: - - develop - -push tag: - <<: *setup_docker_environment - stage: push - variables: - GIT_STRATEGY: none - script: - - export IMAGE_NAME="$CONTAINER_IMAGE:$CI_COMMIT_TAG" - - export LATEST_IMAGE_NAME="$CONTAINER_IMAGE:latest" - - docker tag $TEMP_IMAGE_NAME $IMAGE_NAME - - docker tag $TEMP_IMAGE_NAME $LATEST_IMAGE_NAME - - docker push $IMAGE_NAME - - docker push $LATEST_IMAGE_NAME - only: - - tags - -cleanup temp image: - <<: *setup_docker_environment - stage: cleanup - when: always - script: - - docker rmi $TEMP_IMAGE_NAME || true diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..2d65eb3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,34 @@ +sudo: required + +language: go +go: + - 1.9 + +services: + - docker + +stages: + - test + - publish + +before_install: + - go get -u github.com/golang/dep/cmd/dep + +jobs: + include: + - stage: test + script: + - dep ensure + - go test ./... + - stage: publish + script: + - docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" + - dep ensure + - > + env GOOS=linux + go build + -o release/chrly + -ldflags "-X github.com/elyby/chrly/bootstrap.version=latest" + main.go + - docker build -t elyby/chrly . + - docker push elyby/chrly diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb83c07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +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"] diff --git a/Gopkg.lock b/Gopkg.lock index f2630e1..264c1ed 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -2,10 +2,10 @@ [[projects]] - name = "github.com/assembla/cony" - packages = ["."] - revision = "dd62697b0adb9adfda8589520cb85f4cbc2361f1" - version = "v0.3.2" + name = "github.com/SermoDigital/jose" + packages = [".","crypto","jws","jwt"] + revision = "f6df55f235c24f236d11dbcf665249a59ac2021f" + version = "1.1" [[projects]] name = "github.com/certifi/gocertifi" @@ -82,8 +82,8 @@ [[projects]] branch = "master" name = "github.com/mono83/slf" - packages = [".","filters","params","rays","recievers","recievers/ansi","recievers/statsd","wd"] - revision = "8188a95c8d6b74c43953abb38b8bd6fdbc412ff5" + packages = [".","filters","params","rays","recievers","recievers/sentry","recievers/statsd","recievers/writer","wd"] + revision = "79153e9636db86e1c6b74d74dd04176f257a4f2d" [[projects]] branch = "master" @@ -125,7 +125,7 @@ branch = "master" name = "github.com/spf13/cobra" packages = ["."] - revision = "3c0b56b677e04926dfa835a1b3f11cd4f62f076e" + revision = "0c34d16c3123764e413b9ed982ada58b1c3d53ea" [[projects]] branch = "master" @@ -145,18 +145,19 @@ revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7" version = "v1.0.0" -[[projects]] - branch = "master" - name = "github.com/streadway/amqp" - packages = ["."] - revision = "2cbfe40c9341ad63ba23e53013b3ddc7989d801c" - [[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" @@ -169,12 +170,6 @@ packages = ["internal/gen","internal/triegen","internal/ucd","transform","unicode/cldr","unicode/norm"] revision = "bd91bbf73e9a4a801adbfb97133c992678533126" -[[projects]] - name = "gopkg.in/h2non/gock.v1" - packages = ["."] - revision = "84d599244901620fb3eb96473eb9e50619f69b47" - version = "v1.0.6" - [[projects]] branch = "v2" name = "gopkg.in/yaml.v2" @@ -184,6 +179,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "dd545fafc23f9b6429b5b679ad5c213c14c819f1e4ea381823acf338651122e1" + inputs-digest = "e6bd87f630333e3e5b03bea33720c3281a9094551bd5ced436062157fe51ab71" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index da94f32..868b732 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,4 +1,4 @@ -ignored = ["elyby/minecraft-skinsystem"] +ignored = ["github.com/elyby/chrly"] [[constraint]] name = "github.com/gorilla/mux" @@ -12,6 +12,7 @@ ignored = ["elyby/minecraft-skinsystem"] [[constraint]] name = "github.com/spf13/cobra" + branch = "master" [[constraint]] name = "github.com/spf13/viper" @@ -20,8 +21,13 @@ ignored = ["elyby/minecraft-skinsystem"] name = "github.com/getsentry/raven-go" [[constraint]] - name = "github.com/assembla/cony" - version = "^0.3.2" + 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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d8eee38 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 Ely.by (http://ely.by) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 675441c..878629a 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,252 @@ -# Ely.by Minecraft Skinsystem +# Chrly -Реализация API системы скинов для Minecraft v4. +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. -## Config +## Installation -Конфигурация может задаваться посредством любого из перечисленных форматов файлов: JSON, TOML, YAML, HCL и -Java properties. Кроме того, параметры конфигурации могут перезаписываться доступными при запуске программы -ENV переменными. +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. -> **Заметка**: ENV переменные именуются как KEY.SUBKEY.SUBSUBKEY, т.е. все символы должны быть заглавными, - а точки должны отделять уровень вложенности. +```yml +version: '2' +services: + app: + image: elyby/chrly + hostname: chrly0 + restart: always + links: + - redis + volumes: + - ./data/capes:/data/capes + ports: + - "80:80" + environment: + CHRLY_SECRET: replace_this_value_in_production -Пример файла конфигурации находится в [config.dist.yml](config.dist.yml). Внутри dist-файла есть комментарии, -поясняющие назначение тех или иных параметров. Для работы его следует скопировать в локальный `config.yml` -и отредактировать под свои нужды. - -## Развёртывание - -Деплоить проект можно двумя способами: - -1. Скомпилировав и запустив бинарный файл, а также обеспечив ему доступ ко всем необходмым сервисам. - -2. Используя Docker и docker-compose. - -*Первый случай не буду описывать, т.к. долго, мучительно и никто так делать не будет, я гарантирую это*, -поэтому перейдём сразу ко второму. - -Прежде всего необходимо установить [Docker](https://docs.docker.com/engine/installation/) и -[docker-compose](https://docs.docker.com/compose/install/). - -Для запуска последней версии проекта достаточно скопировать содержимое файла -[docker/docker-compose.prod.yml](docker/docker-compose.prod.yml) в файл `docker-compose.yml` непосредственно -на месте установки, после чего ввести в консоль команду: - -```sh -docker-compose up -d + redis: + image: redis:4.0-32bit + restart: always + volumes: + - ./data/redis:/data ``` -Web-приложение, amqp worker и все сопутствующие сервисы будут автоматически запущены. Данные из контейнеров -будут синхронизироваться в папку `data`. +Chrly will mount some volumes on the host machine to persist storage for capes and Redis database. -## Разработка +### Config -Перво-наперво необходимо [установить последнюю версию Go](https://golang.org/doc/install) и сконфигурировать -переменную окружения GOPATH, а также установить инструмент контроля версий [dep](https://github.com/golang/dep). - -Затем можно склонировать репозиторий хитрым способом, чтобы удовлетворить все прекрасные особенности Go: +Application's configuration is based on the environment variables. You can adjust config by modifying `environment` key +inside your `docker-compose.yml` file. After value will have been changed, container should be stopped and recreated. +If environment variables have been changed, Docker will automatically recreate the container, so you only need to `stop` +and `up` it: ```sh -# Сперва создадим подпапку для приватных Go проектов Ely.by -mkdir -p $GOPATH/src/elyby -# Затем непосредственно клинируем репозиторий туда, где его ожидает увидеть Go -git clone git@gitlab.ely.by:elyby/minecraft-skinsystem.git $GOPATH/src/elyby/minecraft-skinsystem -# Переходим в папку проекта -cd $GOPATH/src/elyby/minecraft-skinsystem -# Устанавливаем зависимости +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` | + +If something goes wrong, you can always access logs by executing `docker-compose logs -f app`. + +## Endpoints + +Each endpoint that accepts `username` as a part of an url takes it case insensitive. `.png` part can be omitted too. + +#### `GET /skins/{username}.png` + +This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll +respond with the `301` redirect to that url. If 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. + +#### `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. + +#### `GET /textures/{username}` + +This endpoint forms response payloads as if it was the `textures`' property, but without base64 encoding. For example: + +```json +{ + "SKIN": { + "url": "http://ely.by/minecraft/skins/skin.png", + "hash": "55d2a8848764f5ff04012cdb093458bd", + "metadata": { + "model": "slim" + } + }, + "CAPE": { + "url": "http://skinsystem.ely.by/cloaks/username", + "hash": "424ff79dce9940af89c28ad80de8aaad" + } +} +``` + +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. + +That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined +operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request +to the Chrly server and put the result in your hasJoined response. + +#### `GET /textures/signed/{username}` + +Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if +you have your own source of 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. + +Response example: + +```json +{ + "id": "0f657aa8bfbe415db7005750090d3af3", + "name": "username", + "properties": [ + { + "name": "textures", + "signature": "signature value", + "value": "base64 encoded value" + }, + { + "name": "chrly", + "value": "how do you tame a horse in Minecraft?" + } + ] +} +``` + +If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent. + +#### `GET /skins?name={username}` + +Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username +placeholder wasn't used. + +#### `GET /cloaks?name={username}` + +Equivalent of the `GET /cloaks/{username}.png`, but constructed especially for old Minecraft versions, where username +placeholder wasn't used. + +### Records manipulating API + +Each request to the internal API should be performed with the Bearer authorization header. Example curl request: + +```sh +curl -X POST -i http://chrly.domain.com/api/skins \ + -H "Authorization: Bearer Ym9zY236Ym9zY28=" +``` + +You can obtain token by executing `docker-compose run --rm app token`. + +#### `POST /api/skins` + +> **Warning**: skin uploading via `skin` field is not implemented for now. + +Endpoint allows you to create or update skin record for a username. To upload skin, you have to send multipart +form data. `form-urlencoded` also supported, but, as you may know, it doesn't support files uploading. + +**Request params:** + +| Field | Type | Description | +|-----------------|--------|--------------------------------------------------------------------------------| +| identityId | int | Unique record identifier. | +| username | string | Username. Case insensitive. | +| uuid | uuid | UUID of the user. | +| skinId | int | Skin identifier. | +| 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`. | + +If successful you'll receive `201` status code. In the case of failure there will be `400` status code and errors list +as json: + +```json +{ + "errors": { + "identityId": [ + "The identityId field must be numeric" + ] + } +} +``` + +#### `DELETE /api/skins/id:{identityId}` + +Performs record removal by identity id. Request body is not required. On success you will receive `204` status code. +On failure it'll be `404` with the json body: + +```json +{ + "error": "Cannot find record for requested user id" +} +``` + +#### `DELETE /api/skins/{username}` + +Same endpoint as above but it removes record by identity's username. Have the same behavior, but in case of failure +response will be: + +```json +{ + "error": "Cannot find record for requested username" +} +``` + +## Development + +First of all you should install the [latest stable version of Go](https://golang.org/doc/install) and set `GOPATH` +environment variable. + +This project uses [`dep`](https://github.com/golang/dep) for dependencies management, so it +[should be installed](https://github.com/golang/dep#installation) too. + +Then you must fork this repository. Now follow these steps: + +```sh +# Get the source code +go get github.com/elyby/chrly +# Switch to the project folder +cd $GOPATH/src/github.com/elyby/chrly +# Install dependencies (it can take a while) dep ensure +# Add your fork link as a remote +git remote add fork git@github.com:your-username/chrly.git +# Create a new branch for your task +git checkout -b iss-123 ``` -Чтобы запустить проект достаточно написать `go run main.go`, но без файла конфигурации и Redis -программа долго не проработает. Поэтому сперва копируем `config.dist.yml` в `config.yml` и, при необходимости, -затачиваем его под себя. - -Redis можно установить в систему самостоятельно, но гораздо удобнее воспользоваться готовыми сервисами, -описанными в [docker/docker-compose.dev.yml](docker/docker-compose.dev.yml). Для этого просто копируем -`docker-compose.dev.yml` и поднимаем сервисы: +You only need to execute `go run main.go` to run the project, but without Redis database and a secret key it won't work +for very long. You have to export `CHRLY_SECRET` environment variable globally or pass it via `env`: ```sh -cp docker/docker-compose.dev.yml docker-compose.yml +env CHRLY_SECRET=some_local_secret go run main.go serve +``` + +Redis can be installed manually, but if you have [Docker installed](https://docs.docker.com/install/), you can run +predefined docker-compose service. Simply execute the next commands: + +```sh +cp docker-compose.dev.yml docker-compose.yml docker-compose up -d ``` -После этого `go run main.go serve` должен запустить web-сервер без дополнительной модификации файла конфигурации. +If your Redis instance isn't located at the `localhost`, you can change host by editing environment variable +`STORAGE_REDIS_HOST`. + +After all of that `go run main.go serve` should successfully start the application. +To run tests execute `go test ./...`. If your Go version is older than 1.9, then run a `/script/test`. diff --git a/api/accounts/accounts.go b/api/accounts/accounts.go deleted file mode 100644 index a92890d..0000000 --- a/api/accounts/accounts.go +++ /dev/null @@ -1,166 +0,0 @@ -package accounts - -import ( - "encoding/json" - "fmt" - "io" - "io/ioutil" - "net/http" - "net/url" - "path" - "strings" -) - -type Config struct { - Addr string - Id string - Secret string - Scopes []string - - Client *http.Client -} - -type Token struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - config *Config -} - -func (config *Config) GetToken() (*Token, error) { - form := url.Values{} - form.Add("client_id", config.Id) - form.Add("client_secret", config.Secret) - form.Add("grant_type", "client_credentials") - form.Add("scope", strings.Join(config.Scopes, ",")) - - response, err := config.getHttpClient().Post(config.getTokenUrl(), "application/x-www-form-urlencoded", strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - defer response.Body.Close() - - var result *Token - responseError := handleResponse(response) - if responseError != nil { - return nil, responseError - } - - body, _ := ioutil.ReadAll(response.Body) - unmarshalError := json.Unmarshal(body, &result) - if unmarshalError != nil { - return nil, err - } - - result.config = config - - return result, nil -} - -func (config *Config) getTokenUrl() string { - return concatenateHostAndPath(config.Addr, "/api/oauth2/v1/token") -} - -func (config *Config) getHttpClient() *http.Client { - if config.Client == nil { - config.Client = &http.Client{} - } - - return config.Client -} - -type AccountInfoResponse struct { - Id int `json:"id"` - Uuid string `json:"uuid"` - Username string `json:"username"` - Email string `json:"email"` -} - -func (token *Token) AccountInfo(attribute string, value string) (*AccountInfoResponse, error) { - request := token.newRequest("GET", token.accountInfoUrl(), nil) - - query := request.URL.Query() - query.Add(attribute, value) - request.URL.RawQuery = query.Encode() - - response, err := token.config.Client.Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - - var info *AccountInfoResponse - - responseError := handleResponse(response) - if responseError != nil { - return nil, responseError - } - - body, _ := ioutil.ReadAll(response.Body) - json.Unmarshal(body, &info) - - return info, nil -} - -func (token *Token) accountInfoUrl() string { - return concatenateHostAndPath(token.config.Addr, "/api/internal/accounts/info") -} - -func (token *Token) newRequest(method string, urlStr string, body io.Reader) *http.Request { - request, err := http.NewRequest(method, urlStr, body) - if err != nil { - panic(err) - } - - request.Header.Add("Authorization", "Bearer " + token.AccessToken) - - return request -} - -func concatenateHostAndPath(host string, pathToJoin string) string { - u, _ := url.Parse(host) - u.Path = path.Join(u.Path, pathToJoin) - - return u.String() -} - -type UnauthorizedResponse struct {} - -func (err UnauthorizedResponse) Error() string { - return "Unauthorized response" -} - -type ForbiddenResponse struct {} - -func (err ForbiddenResponse) Error() string { - return "Forbidden response" -} - -type NotFoundResponse struct {} - -func (err NotFoundResponse) Error() string { - return "Not found" -} - -type NotSuccessResponse struct { - StatusCode int -} - -func (err NotSuccessResponse) Error() string { - return fmt.Sprintf("Response code is \"%d\"", err.StatusCode) -} - -func handleResponse(response *http.Response) error { - switch status := response.StatusCode; status { - case 200: - return nil - case 401: - return &UnauthorizedResponse{} - case 403: - return &ForbiddenResponse{} - case 404: - return &NotFoundResponse{} - default: - return &NotSuccessResponse{status} - } -} diff --git a/api/accounts/accounts_test.go b/api/accounts/accounts_test.go deleted file mode 100644 index 99be4d2..0000000 --- a/api/accounts/accounts_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package accounts - -import ( - "net/http" - "strings" - "testing" - - testify "github.com/stretchr/testify/assert" - "gopkg.in/h2non/gock.v1" -) - -func TestConfig_GetToken(t *testing.T) { - assert := testify.New(t) - - defer gock.Off() - gock.New("https://account.ely.by"). - Post("/api/oauth2/v1/token"). - Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")). - Reply(200). - JSON(map[string]interface{}{ - "access_token": "mocked-token", - "token_type": "Bearer", - "expires_in": 86400, - }) - - client := &http.Client{} - gock.InterceptClient(client) - - config := &Config{ - Addr: "https://account.ely.by", - Id: "mock-id", - Secret: "mock-secret", - Scopes: []string{"scope1", "scope2"}, - Client: client, - } - - result, err := config.GetToken() - if assert.NoError(err) { - assert.Equal("mocked-token", result.AccessToken) - assert.Equal("Bearer", result.TokenType) - assert.Equal(86400, result.ExpiresIn) - } -} - -func TestToken_AccountInfo(t *testing.T) { - assert := testify.New(t) - - defer gock.Off() - // To test valid behavior - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mock-token"). - Reply(200). - JSON(map[string]interface{}{ - "id": 1, - "uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3", - "username": "dummy", - "email": "dummy@ely.by", - }) - - // To test behavior on invalid or expired token - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mock-token"). - Reply(401). - JSON(map[string]interface{}{ - "name": "Unauthorized", - "message": "Incorrect token", - "code": 0, - "status": 401, - }) - - client := &http.Client{} - gock.InterceptClient(client) - - token := &Token{ - AccessToken: "mock-token", - config: &Config{ - Addr: "https://account.ely.by", - Client: client, - }, - } - - result, err := token.AccountInfo("id", "1") - if assert.NoError(err) { - assert.Equal(1, result.Id) - assert.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", result.Uuid) - assert.Equal("dummy", result.Username) - assert.Equal("dummy@ely.by", result.Email) - } - - result2, err2 := token.AccountInfo("id", "1") - assert.Nil(result2) - assert.Error(err2) - assert.IsType(&UnauthorizedResponse{}, err2) -} diff --git a/api/accounts/auto-refresh-token.go b/api/accounts/auto-refresh-token.go deleted file mode 100644 index 9df3ba5..0000000 --- a/api/accounts/auto-refresh-token.go +++ /dev/null @@ -1,56 +0,0 @@ -package accounts - -type AutoRefresh struct { - token *Token - config *Config - repeatsCount int -} - -const repeatsLimit = 3 - -func (config *Config) GetTokenWithAutoRefresh() *AutoRefresh { - return &AutoRefresh{ - config: config, - } -} - -func (refresher *AutoRefresh) AccountInfo(attribute string, value string) (*AccountInfoResponse, error) { - defer refresher.resetRepeatsCount() - - apiToken, err := refresher.getToken() - if err != nil { - return nil, err - } - - result, err := apiToken.AccountInfo(attribute, value) - if err != nil { - _, isTokenExpire := err.(*UnauthorizedResponse) - if !isTokenExpire || refresher.repeatsCount >= repeatsLimit - 1 { - return nil, err - } - - refresher.repeatsCount++ - refresher.token = nil - - return refresher.AccountInfo(attribute, value) - } - - return result, nil -} - -func (refresher *AutoRefresh) getToken() (*Token, error) { - if refresher.token == nil { - newToken, err := refresher.config.GetToken() - if err != nil { - return nil, err - } - - refresher.token = newToken - } - - return refresher.token, nil -} - -func (refresher *AutoRefresh) resetRepeatsCount() { - refresher.repeatsCount = 0 -} diff --git a/api/accounts/auto-refresh-token_test.go b/api/accounts/auto-refresh-token_test.go deleted file mode 100644 index 4af6b88..0000000 --- a/api/accounts/auto-refresh-token_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package accounts - -import ( - "net/http" - "strings" - "testing" - - testify "github.com/stretchr/testify/assert" - "gopkg.in/h2non/gock.v1" -) - -var config = &Config{ - Addr: "https://account.ely.by", - Id: "mock-id", - Secret: "mock-secret", - Scopes: []string{"scope1", "scope2"}, -} - -func TestConfig_GetTokenWithAutoRefresh(t *testing.T) { - assert := testify.New(t) - - testConfig := &Config{} - *testConfig = *config - - result := testConfig.GetTokenWithAutoRefresh() - assert.Equal(testConfig, result.config) -} - -func TestAutoRefresh_AccountInfo(t *testing.T) { - assert := testify.New(t) - - defer gock.Off() - gock.New("https://account.ely.by"). - Post("/api/oauth2/v1/token"). - Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")). - Reply(200). - JSON(map[string]interface{}{ - "access_token": "mocked-token", - "token_type": "Bearer", - "expires_in": 86400, - }) - - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - Times(2). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mocked-token"). - Reply(200). - JSON(map[string]interface{}{ - "id": 1, - "uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3", - "username": "dummy", - "email": "dummy@ely.by", - }) - - client := &http.Client{} - gock.InterceptClient(client) - - testConfig := &Config{} - *testConfig = *config - testConfig.Client = client - - autoRefresher := testConfig.GetTokenWithAutoRefresh() - result, err := autoRefresher.AccountInfo("id", "1") - if assert.NoError(err) { - assert.Equal(1, result.Id) - assert.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", result.Uuid) - assert.Equal("dummy", result.Username) - assert.Equal("dummy@ely.by", result.Email) - } - - result2, err2 := autoRefresher.AccountInfo("id", "1") - if assert.NoError(err2) { - assert.Equal(result, result2, "Results should still be same without token refreshing") - } -} - -func TestAutoRefresh_AccountInfo2(t *testing.T) { - assert := testify.New(t) - - defer gock.Off() - gock.New("https://account.ely.by"). - Post("/api/oauth2/v1/token"). - Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")). - Reply(200). - JSON(map[string]interface{}{ - "access_token": "mocked-token-1", - "token_type": "Bearer", - "expires_in": 86400, - }) - - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mocked-token-1"). - Reply(200). - JSON(map[string]interface{}{ - "id": 1, - "uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3", - "username": "dummy", - "email": "dummy@ely.by", - }) - - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mocked-token-1"). - Reply(401). - JSON(map[string]interface{}{ - "name": "Unauthorized", - "message": "Incorrect token", - "code": 0, - "status": 401, - }) - - gock.New("https://account.ely.by"). - Post("/api/oauth2/v1/token"). - Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")). - Reply(200). - JSON(map[string]interface{}{ - "access_token": "mocked-token-2", - "token_type": "Bearer", - "expires_in": 86400, - }) - - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mocked-token-2"). - Reply(200). - JSON(map[string]interface{}{ - "id": 1, - "uuid": "0f657aa8-bfbe-415d-b700-5750090d3af3", - "username": "dummy", - "email": "dummy@ely.by", - }) - - client := &http.Client{} - gock.InterceptClient(client) - - testConfig := &Config{} - *testConfig = *config - testConfig.Client = client - - autoRefresher := testConfig.GetTokenWithAutoRefresh() - result, err := autoRefresher.AccountInfo("id", "1") - if assert.NoError(err) { - assert.Equal(1, result.Id) - assert.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", result.Uuid) - assert.Equal("dummy", result.Username) - assert.Equal("dummy@ely.by", result.Email) - } - - result2, err2 := autoRefresher.AccountInfo("id", "1") - if assert.NoError(err2) { - assert.Equal(result, result2, "Results should still be same with refreshed token") - } -} - -func TestAutoRefresh_AccountInfo3(t *testing.T) { - assert := testify.New(t) - - defer gock.Off() - gock.New("https://account.ely.by"). - Post("/api/oauth2/v1/token"). - Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")). - Reply(200). - JSON(map[string]interface{}{ - "access_token": "mocked-token-1", - "token_type": "Bearer", - "expires_in": 86400, - }) - - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mocked-token-1"). - Reply(404). - JSON(map[string]interface{}{ - "name": "Not Found", - "message": "Page not found.", - "code": 0, - "status": 404, - }) - - client := &http.Client{} - gock.InterceptClient(client) - - testConfig := &Config{} - *testConfig = *config - testConfig.Client = client - - autoRefresher := testConfig.GetTokenWithAutoRefresh() - result, err := autoRefresher.AccountInfo("id", "1") - assert.Nil(result) - assert.Error(err) - assert.IsType(&NotFoundResponse{}, err) -} - -func TestAutoRefresh_AccountInfo4(t *testing.T) { - assert := testify.New(t) - - defer gock.Off() - gock.New("https://account.ely.by"). - Post("/api/oauth2/v1/token"). - Times(3). - Body(strings.NewReader("client_id=mock-id&client_secret=mock-secret&grant_type=client_credentials&scope=scope1%2Cscope2")). - Reply(200). - JSON(map[string]interface{}{ - "access_token": "mocked-token-1", - "token_type": "Bearer", - "expires_in": 86400, - }) - - gock.New("https://account.ely.by"). - Get("/api/internal/accounts/info"). - Times(3). - MatchParam("id", "1"). - MatchHeader("Authorization", "Bearer mocked-token-1"). - Reply(401). - JSON(map[string]interface{}{ - "name": "Unauthorized", - "message": "Incorrect token", - "code": 0, - "status": 401, - }) - - client := &http.Client{} - gock.InterceptClient(client) - - testConfig := &Config{} - *testConfig = *config - testConfig.Client = client - - autoRefresher := testConfig.GetTokenWithAutoRefresh() - result, err := autoRefresher.AccountInfo("id", "1") - assert.Nil(result) - assert.Error(err) - if !assert.IsType(&UnauthorizedResponse{}, err) { - t.Fatal(err) - } -} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..7d86618 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,82 @@ +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" +} diff --git a/auth/jwt_test.go b/auth/jwt_test.go new file mode 100644 index 0000000..00fc65a --- /dev/null +++ b/auth/jwt_test.go @@ -0,0 +1,97 @@ +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) +} diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index d9c13ff..d8774ee 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -1,18 +1,14 @@ package bootstrap import ( - "fmt" - "net/url" "os" - "github.com/assembla/cony" "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" - - "elyby/minecraft-skinsystem/logger/receivers/sentry" ) var version = "" @@ -66,26 +62,3 @@ func CreateLogger(statsdAddr string, sentryAddr string) (wd.Watchdog, error) { return wd.New("", "").WithParams(rays.Host), nil } - -type RabbitMQConfig struct { - Username string - Password string - Host string - Port int - Vhost string -} - -func CreateRabbitMQClient(config *RabbitMQConfig) *cony.Client { - addr := fmt.Sprintf( - "amqp://%s:%s@%s:%d/%s", - config.Username, - config.Password, - config.Host, - config.Port, - url.PathEscape(config.Vhost), - ) - - client := cony.NewClient(cony.URL(addr), cony.Backoff(cony.DefaultBackoff)) - - return client -} diff --git a/cmd/amqpWorker.go b/cmd/amqpWorker.go deleted file mode 100644 index ab42f69..0000000 --- a/cmd/amqpWorker.go +++ /dev/null @@ -1,67 +0,0 @@ -package cmd - -import ( - "fmt" - "log" - - "github.com/spf13/cobra" - "github.com/spf13/viper" - - "elyby/minecraft-skinsystem/api/accounts" - "elyby/minecraft-skinsystem/bootstrap" - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/worker" -) - -var amqpWorkerCmd = &cobra.Command{ - Use: "amqp-worker", - Short: "Launches a worker which listens to events and processes them", - 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("Creating AMQP client") - amqpClient := bootstrap.CreateRabbitMQClient(&bootstrap.RabbitMQConfig{ - Host: viper.GetString("amqp.host"), - Port: viper.GetInt("amqp.port"), - Username: viper.GetString("amqp.username"), - Password: viper.GetString("amqp.password"), - Vhost: viper.GetString("amqp.vhost"), - }) - - accountsApi := (&accounts.Config{ - Addr: viper.GetString("api.accounts.host"), - Id: viper.GetString("api.accounts.id"), - Secret: viper.GetString("api.accounts.secret"), - Scopes: viper.GetStringSlice("api.accounts.scopes"), - }).GetTokenWithAutoRefresh() - - services := &worker.Services{ - Logger: logger, - AmqpClient: amqpClient, - SkinsRepo: skinsRepo, - AccountsAPI: accountsApi, - } - - if err := services.Run(); err != nil { - logger.Error(fmt.Sprintf("Cannot initialize worker: %+v", err)) - } - }, -} - -func init() { - RootCmd.AddCommand(amqpWorkerCmd) -} diff --git a/cmd/root.go b/cmd/root.go index 7c32742..5b973bd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,16 +3,18 @@ package cmd import ( "fmt" "os" + "strings" + + "github.com/elyby/chrly/bootstrap" "github.com/spf13/cobra" "github.com/spf13/viper" ) -var cfgFile string - var RootCmd = &cobra.Command{ - Use: "", - Short: "Nothing here", + 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. @@ -24,23 +26,12 @@ func Execute() { } } -func init() { +func init() { cobra.OnInitialize(initConfig) - RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.test.yaml)") } func initConfig() { - if cfgFile != "" { - viper.SetConfigFile(cfgFile) - } else { - viper.SetConfigName("config") - viper.AddConfigPath("/etc/minecraft-skinsystem") - viper.AddConfigPath(".") - } - viper.AutomaticEnv() - - if err := viper.ReadInConfig(); err == nil { - fmt.Println("Using config file:", viper.ConfigFileUsed()) - } + replacer := strings.NewReplacer(".", "_") + viper.SetEnvKeyReplacer(replacer) } diff --git a/cmd/serve.go b/cmd/serve.go index d67ef97..32794eb 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -4,17 +4,19 @@ import ( "fmt" "log" + "github.com/elyby/chrly/auth" + "github.com/spf13/cobra" "github.com/spf13/viper" - "elyby/minecraft-skinsystem/bootstrap" - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/http" + "github.com/elyby/chrly/bootstrap" + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/http" ) var serveCmd = &cobra.Command{ Use: "serve", - Short: "Runs the system server skins", + 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 { @@ -42,9 +44,10 @@ var serveCmd = &cobra.Command{ cfg := &http.Config{ ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")), - SkinsRepo: skinsRepo, - CapesRepo: capesRepo, - Logger: logger, + SkinsRepo: skinsRepo, + CapesRepo: capesRepo, + Logger: logger, + Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))}, } if err := cfg.Run(); err != nil { @@ -55,4 +58,11 @@ var serveCmd = &cobra.Command{ 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") } diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 0000000..2380d36 --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,29 @@ +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) +} diff --git a/cmd/version.go b/cmd/version.go index cd08f82..e1196fe 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -4,13 +4,13 @@ import ( "fmt" "github.com/spf13/cobra" - "elyby/minecraft-skinsystem/bootstrap" + "github.com/elyby/chrly/bootstrap" "runtime" ) var versionCmd = &cobra.Command{ Use: "version", - Short: "Show the Minecraft Skinsystem version information", + 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()) diff --git a/config.dist.yml b/config.dist.yml deleted file mode 100644 index 6270db8..0000000 --- a/config.dist.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Main server configuration. Actually you don't want to change it, -# but you able to change host or port, that will be used by serve command -server: - host: localhost - port: 80 - -# Worker listen to AMQP events, so it should know how to connect to any -# AMQP provider (actually RabbitMQ). You should not escape any vhost -# characters, 'cause it will be done by application automatically -amqp: - host: localhost - port: 5672 - username: amqp-user - password: amqp-password - vhost: / - -# Both of web or worker depends on storage. -storage: - # For now app require Redis and don't support any other backends to store - # skins, but in the future we can have more backends. Poll size tune amount - # of connections to the redis. It's not recommended to set it less then 2 - # because it will lead to panic on high load. - redis: - host: localhost - port: 6379 - poolSize: 10 - - # Filesystem storage used to store capes. basePath specify absolute or relative - # path to storage and capesDirName specify which folder in this base path will - # be used to search capes. - filesystem: - basePath: data - capesDirName: capes - -# Accounts Ely.by internal API will be used in cases, when by some reasons -# information about user will be unavailable in the app storage. -api: - accounts: - host: https://account.ely.by - id: app-id - secret: secret - scopes: - - internal_account_info - -# StatsD can be used to collect metrics -# statsd: -# addr: localhost:3746 - -# Sentry can be used to collect app errors -# sentry: -# dsn: "https://public:private@your.sentry.io/1" diff --git a/db/factory.go b/db/factory.go index 62eaba5..93f88c8 100644 --- a/db/factory.go +++ b/db/factory.go @@ -3,7 +3,7 @@ package db import ( "github.com/spf13/viper" - "elyby/minecraft-skinsystem/interfaces" + "github.com/elyby/chrly/interfaces" ) type StorageFactory struct { diff --git a/db/filesystem.go b/db/filesystem.go index 376c167..cbc6251 100644 --- a/db/filesystem.go +++ b/db/filesystem.go @@ -5,8 +5,8 @@ import ( "path" "strings" - "elyby/minecraft-skinsystem/interfaces" - "elyby/minecraft-skinsystem/model" + "github.com/elyby/chrly/interfaces" + "github.com/elyby/chrly/model" ) type FilesystemFactory struct { diff --git a/db/redis.go b/db/redis.go index 0e963a6..08f1359 100644 --- a/db/redis.go +++ b/db/redis.go @@ -14,17 +14,19 @@ import ( "github.com/mediocregopher/radix.v2/redis" "github.com/mediocregopher/radix.v2/util" - "elyby/minecraft-skinsystem/interfaces" - "elyby/minecraft-skinsystem/model" + "github.com/elyby/chrly/interfaces" + "github.com/elyby/chrly/model" ) type RedisFactory struct { Host string Port int PoolSize int - connection util.Cmder + 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 { @@ -38,7 +40,7 @@ func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error panic("capes repository not supported for this storage type") } -func (f RedisFactory) getConnection() (util.Cmder, error) { +func (f RedisFactory) getConnection() (*pool.Pool, error) { if f.connection == nil { if f.Host == "" { return nil, &ParamRequired{"host"} @@ -49,7 +51,7 @@ func (f RedisFactory) getConnection() (util.Cmder, error) { } addr := fmt.Sprintf("%s:%d", f.Host, f.Port) - conn, err := createConnection(addr, f.PoolSize) + conn, err := pool.New("tcp", addr, f.PoolSize) if err != nil { return nil, err } @@ -66,7 +68,7 @@ func (f RedisFactory) getConnection() (util.Cmder, error) { } log.Println("Redis not pinged. Try to reconnect") - conn, err := createConnection(addr, f.PoolSize) + 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) @@ -82,27 +84,44 @@ func (f RedisFactory) getConnection() (util.Cmder, error) { return f.connection, nil } -func createConnection(addr string, poolSize int) (util.Cmder, error) { - if poolSize > 1 { - return pool.New("tcp", addr, poolSize) - } else { - return redis.Dial("tcp", addr) - } -} - type redisDb struct { - conn util.Cmder + conn *pool.Pool } -const accountIdToUsernameKey string = "hash:username-to-account-id" +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 := buildKey(username) - response := db.conn.Cmd("GET", redisKey) + redisKey := buildUsernameKey(username) + response := conn.Cmd("GET", redisKey) if response.IsType(redis.Nil) { return nil, &SkinNotFoundError{username} } @@ -128,37 +147,72 @@ func (db *redisDb) FindByUsername(username string) (*model.Skin, error) { return skin, nil } -func (db *redisDb) FindByUserId(id int) (*model.Skin, error) { - response := db.conn.Cmd("HGET", accountIdToUsernameKey, id) +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 db.FindByUsername(username) + return findByUsername(username, conn) } -func (db *redisDb) Save(skin *model.Skin) error { - conn := db.conn - if poolConn, isPool := conn.(*pool.Pool); isPool { - conn, _ = poolConn.Get() +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") - // Если пользователь сменил ник, то мы должны удать его ключ - if skin.OldUsername != "" && skin.OldUsername != skin.Username { - conn.Cmd("DEL", buildKey(skin.OldUsername)) + 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", buildKey(skin.Username), zlibEncode(str)) + conn.Cmd("SET", buildUsernameKey(skin.Username), zlibEncode(str)) conn.Cmd("EXEC") @@ -167,11 +221,10 @@ func (db *redisDb) Save(skin *model.Skin) error { return nil } -func buildKey(username string) string { +func buildUsernameKey(username string) string { return "username:" + strings.ToLower(username) } -//noinspection GoUnusedFunction func zlibEncode(str []byte) []byte { var buff bytes.Buffer writer := zlib.NewWriter(&buff) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..20e6f82 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,14 @@ +# This file can be used to start up necessary services. +# Copy it into the docker-compose.yml: +# > cp docker-compose.dev.yml docker-compose.yml +# And then run it: +# > docker-compose up -d + +version: '2' +services: + redis: + image: redis:4.0-32bit + ports: + - "6379:6379" + volumes: + - ./data/redis:/data diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..ca4e940 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,27 @@ +# 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 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..de3fa34 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,12 @@ +#!/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 "$@" diff --git a/docker/Dockerfile b/docker/Dockerfile deleted file mode 100644 index dc6d420..0000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM alpine:3.6 - -RUN apk --update add ca-certificates \ - && update-ca-certificates \ - && rm -rf /var/cache/apk/* - -COPY docker/docker-entrypoint.sh /usr/local/bin/ -COPY docker/config.dist.yml /usr/local/etc/minecraft-skinsystem/ - -COPY minecraft-skinsystem /usr/local/bin/ - -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["serve"] diff --git a/docker/config.dist.yml b/docker/config.dist.yml deleted file mode 100644 index 8045c06..0000000 --- a/docker/config.dist.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Main server configuration. Actually you don't want to change it, -# but you able to change host or port, that will be used by serve command -server: - host: # leave host empty to allow Docker publish port - port: 80 - -# Worker listen to AMQP events, so it should know how to connect to any -# AMQP provider (actually RabbitMQ). You should not escape any vhost -# characters, 'cause it will be done by application automatically -amqp: - host: rabbitmq - port: 5672 - username: minecraft-skinsystem-app - password: minecraft-skinsystem-app-password - vhost: / - -# Both of web or worker depends on storage. -storage: - # For now app require Redis and don't support any other backends to store - # skins, but in the future we can have more backends. Poll size tune amount - # of connections to the redis. It's not recommended to set it less then 2 - # because it will lead to panic on high load. - redis: - host: redis - port: 6379 - poolSize: 10 - - # Filesystem storage used to store capes. basePath specify absolute or relative - # path to storage and capesDirName specify which folder in this base path will - # be used to search capes. - filesystem: - basePath: /data - capesDirName: capes - -# Accounts Ely.by internal API will be used in cases, when by some reasons -# information about user will be unavailable in the app storage. -api: - accounts: - host: https://account.ely.by - id: app-id - secret: secret - scopes: - - internal_account_info - -# StatsD can be used to collect metrics -# statsd: -# addr: localhost:3746 - -# Sentry can be used to collect app errors -# sentry: -# dsn: https://public:private@your.sentry.io/1 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml deleted file mode 100644 index 8b6c29f..0000000 --- a/docker/docker-compose.dev.yml +++ /dev/null @@ -1,46 +0,0 @@ -# This compose file contains necessary docker-compose config to quick start -# services required by app. Ports published to host. -# -# Usage: -# 1. Clone this file as docker-compose.yml: -# cp docker/docker-compose.dev.yml docker-compose.yml -# -# 2. If necessary, then you can fix configuration to your environment. -# Then start all services: -# docker-compose up -d -# -# 3. Pass to the project configuration links to this services: -# amqp: -# host: localhost -# port: 5672 -# username: ely -# password: ely -# vhost: /ely -# -# storage: -# redis: -# host: localhost -# port: 6379 -# poolSize: 10 -# -# 4. After job is done all services can be stopped: -# docker-compose stop - -version: '2' -services: - redis: - image: redis:3.2-32bit - ports: - - "6379:6379" - volumes: - - ./data/redis:/data - - rabbitmq: - image: rabbitmq:3.6-management-alpine - ports: - - "5672:5672" - - "15672:15672" - environment: - RABBITMQ_DEFAULT_USER: "ely" - RABBITMQ_DEFAULT_PASS: "ely" - RABBITMQ_DEFAULT_VHOST: "/ely" diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml deleted file mode 100644 index 54b4512..0000000 --- a/docker/docker-compose.prod.yml +++ /dev/null @@ -1,36 +0,0 @@ -version: '2' -services: - web: - image: registry.ely.by/elyby/skinsystem:latest - restart: always - ports: - - "80:80" - links: - - redis - volumes: - - ./data/capes:/data/capes - - ./config/minecraft-skinsystem:/etc/minecraft-skinsystem - - worker: - image: registry.ely.by/elyby/skinsystem:latest - restart: always - links: - - redis - - rabbitmq - command: ["amqp-worker"] - volumes: - - ./config/minecraft-skinsystem:/etc/minecraft-skinsystem - - redis: - image: redis:3.2-32bit # 32-bit version used to decrease memory usage - restart: always - volumes: - - ./data/redis:/data - - rabbitmq: - image: rabbitmq:3.6-alpine - restart: always - environment: - RABBITMQ_DEFAULT_USER: minecraft-skinsystem-app - RABBITMQ_DEFAULT_PASS: minecraft-skinsystem-app-password - RABBITMQ_DEFAULT_VHOST: / diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh deleted file mode 100755 index 54c11ea..0000000 --- a/docker/docker-entrypoint.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -set -e - -CONFIG="/etc/minecraft-skinsystem/config.yml" - -if [ ! -f "$CONFIG" ]; then - mkdir -p $(dirname "${CONFIG}") - cp /usr/local/etc/minecraft-skinsystem/config.dist.yml "$CONFIG" -fi - -if [ "$1" = "serve" ] || [ "$1" = "amqp-worker" ]; then - set -- minecraft-skinsystem "$@" -fi - -exec "$@" diff --git a/http/api.go b/http/api.go new file mode 100644 index 0000000..ac737f1 --- /dev/null +++ b/http/api.go @@ -0,0 +1,256 @@ +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" +) + +func init() { + govalidator.AddCustomRule("md5", func(field string, rule string, message string, value interface{}) error { + val := []byte(value.(string)) + if ok, _ := regexp.Match(`^[a-f0-9]{32}$`, val); !ok { + if message == "" { + message = fmt.Sprintf("The %s field must be a valid md5 hash", field) + } + + return errors.New(message) + } + + return nil + }) + + 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) + }) +} + +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"}, + "skinId": {"required", "numeric", "min:1"}, + "url": {"url"}, + "file:skin": {"ext:png", "size:24576", "mime:image/png"}, + "hash": {"md5"}, + "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) +} diff --git a/http/api_test.go b/http/api_test.go new file mode 100644 index 0000000..e0f2fee --- /dev/null +++ b/http/api_test.go @@ -0,0 +1,505 @@ +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{ + "hash": {"this is not md5"}, + "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" + ], + "hash": [ + "The hash field must be a valid md5 hash" + ], + "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 +} diff --git a/http/cape_test.go b/http/cape_test.go index ed50c1e..fe20a48 100644 --- a/http/cape_test.go +++ b/http/cape_test.go @@ -11,8 +11,8 @@ import ( "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/model" + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/model" ) func TestConfig_Cape(t *testing.T) { @@ -21,14 +21,14 @@ func TestConfig_Cape(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, _, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) cape := createCape() - capesRepo.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{ + mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{ File: bytes.NewReader(cape), }, nil) - wd.EXPECT().IncCounter("capes.request", int64(1)) + mocks.Log.EXPECT().IncCounter("capes.request", int64(1)) req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/mocked_username", nil) w := httptest.NewRecorder() @@ -48,10 +48,10 @@ func TestConfig_Cape2(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, _, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - capesRepo.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"}) - wd.EXPECT().IncCounter("capes.request", int64(1)) + 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() @@ -69,15 +69,15 @@ func TestConfig_CapeGET(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, _, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) cape := createCape() - capesRepo.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{ + mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{ File: bytes.NewReader(cape), }, nil) - wd.EXPECT().IncCounter("capes.request", int64(1)).Times(0) - wd.EXPECT().IncCounter("capes.get_request", int64(1)) + 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() @@ -97,11 +97,11 @@ func TestConfig_CapeGET2(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, _, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - capesRepo.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"}) - wd.EXPECT().IncCounter("capes.request", int64(1)).Times(0) - wd.EXPECT().IncCounter("capes.get_request", int64(1)) + 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() diff --git a/http/face.go b/http/face.go deleted file mode 100644 index 2032f39..0000000 --- a/http/face.go +++ /dev/null @@ -1,27 +0,0 @@ -package http - -import ( - "net/http" - - "github.com/gorilla/mux" -) - -const defaultHash = "default" - -func (cfg *Config) Face(response http.ResponseWriter, request *http.Request) { - cfg.Logger.IncCounter("faces.request", 1) - username := parseUsername(mux.Vars(request)["username"]) - rec, err := cfg.SkinsRepo.FindByUsername(username) - var hash string - if err != nil || rec.SkinId == 0 { - hash = defaultHash - } else { - hash = rec.Hash - } - - http.Redirect(response, request, buildElyUrl(buildFaceUrl(hash)), 301) -} - -func buildFaceUrl(hash string) string { - return "/minecraft/skin_buffer/faces/" + hash + ".png" -} diff --git a/http/face_test.go b/http/face_test.go deleted file mode 100644 index f61daff..0000000 --- a/http/face_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package http - -import ( - "net/http/httptest" - "testing" - - "github.com/golang/mock/gomock" - testify "github.com/stretchr/testify/assert" - - "elyby/minecraft-skinsystem/db" -) - -func TestConfig_Face(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, skinsRepo, _, wd := setupMocks(ctrl) - - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - wd.EXPECT().IncCounter("faces.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user/face.png", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://ely.by/minecraft/skin_buffer/faces/55d2a8848764f5ff04012cdb093458bd.png", resp.Header.Get("Location")) -} - -func TestConfig_Face2(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, skinsRepo, _, wd := setupMocks(ctrl) - - skinsRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{"mock_user"}) - wd.EXPECT().IncCounter("faces.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user/face.png", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://ely.by/minecraft/skin_buffer/faces/default.png", resp.Header.Get("Location")) -} diff --git a/http/http.go b/http/http.go index b07fdd0..a539a15 100644 --- a/http/http.go +++ b/http/http.go @@ -13,7 +13,7 @@ import ( "github.com/gorilla/mux" "github.com/mono83/slf/wd" - "elyby/minecraft-skinsystem/interfaces" + "github.com/elyby/chrly/interfaces" ) type Config struct { @@ -22,6 +22,7 @@ type Config struct { SkinsRepo interfaces.SkinsRepository CapesRepo interfaces.CapesRepository Logger wd.Watchdog + Auth interfaces.AuthChecker } func (cfg *Config) Run() error { @@ -54,11 +55,13 @@ func (cfg *Config) CreateHandler() http.Handler { 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") - router.HandleFunc("/skins/{username}/face", cfg.Face).Methods("GET") - router.HandleFunc("/skins/{username}/face.png", cfg.Face).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) @@ -74,15 +77,6 @@ func parseUsername(username string) string { return username } -func buildElyUrl(route string) string { - prefix := "http://ely.by" - if !strings.HasPrefix(route, prefix) { - route = prefix + route - } - - return route -} - func waitForSignal() os.Signal { ch := make(chan os.Signal) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) diff --git a/http/http_test.go b/http/http_test.go index be23234..884899a 100644 --- a/http/http_test.go +++ b/http/http_test.go @@ -6,8 +6,8 @@ import ( "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" - "elyby/minecraft-skinsystem/interfaces/mock_interfaces" - "elyby/minecraft-skinsystem/interfaces/mock_wd" + "github.com/elyby/chrly/interfaces/mock_interfaces" + "github.com/elyby/chrly/interfaces/mock_wd" ) func TestParseUsername(t *testing.T) { @@ -16,25 +16,31 @@ func TestParseUsername(t *testing.T) { assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end") } -func TestBuildElyUrl(t *testing.T) { - assert := testify.New(t) - assert.Equal("http://ely.by/route", buildElyUrl("/route"), "Function should add prefix to the provided relative url.") - assert.Equal("http://ely.by/test/route", buildElyUrl("http://ely.by/test/route"), "Function should do not add prefix to the provided prefixed url.") +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, - *mock_interfaces.MockSkinsRepository, - *mock_interfaces.MockCapesRepository, - *mock_wd.MockWatchdog, + *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, - Logger: wd, - }, skinsRepo, capesRepo, wd + Auth: authChecker, + Logger: wd, + }, &mocks{ + Skins: skinsRepo, + Capes: capesRepo, + Auth: authChecker, + Log: wd, + } } diff --git a/http/not_found.go b/http/not_found.go index 3328634..33e4705 100644 --- a/http/not_found.go +++ b/http/not_found.go @@ -5,11 +5,10 @@ import ( "net/http" ) -func (cfg *Config) NotFound(response http.ResponseWriter, request *http.Request) { +func (cfg *Config) NotFound(response http.ResponseWriter, request *http.Request) { data, _ := json.Marshal(map[string]string{ - "status": "404", + "status": "404", "message": "Not Found", - "link": "http://docs.ely.by/skin-system.html", }) response.Header().Set("Content-Type", "application/json") diff --git a/http/not_found_test.go b/http/not_found_test.go index 44c8a81..dfab394 100644 --- a/http/not_found_test.go +++ b/http/not_found_test.go @@ -22,7 +22,6 @@ func TestConfig_NotFound(t *testing.T) { response, _ := ioutil.ReadAll(resp.Body) assert.JSONEq(`{ "status": "404", - "message": "Not Found", - "link": "http://docs.ely.by/skin-system.html" + "message": "Not Found" }`, string(response)) } diff --git a/http/signed_textures.go b/http/signed_textures.go index 49950b1..158cdaa 100644 --- a/http/signed_textures.go +++ b/http/signed_textures.go @@ -11,7 +11,6 @@ import ( type signedTexturesResponse struct { Id string `json:"id"` Name string `json:"name"` - IsEly bool `json:"ely,omitempty"` Props []property `json:"properties"` } @@ -41,8 +40,8 @@ func (cfg *Config) SignedTextures(response http.ResponseWriter, request *http.Re Value: rec.MojangTextures, }, { - Name: "ely", - Value: "but why are you asking?", + Name: "chrly", + Value: "how do you tame a horse in Minecraft?", }, }, } diff --git a/http/signed_textures_test.go b/http/signed_textures_test.go index 48d728a..41934f5 100644 --- a/http/signed_textures_test.go +++ b/http/signed_textures_test.go @@ -8,7 +8,7 @@ import ( "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" - "elyby/minecraft-skinsystem/db" + "github.com/elyby/chrly/db" ) func TestConfig_SignedTextures(t *testing.T) { @@ -17,10 +17,10 @@ func TestConfig_SignedTextures(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, _, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - wd.EXPECT().IncCounter("signed_textures.request", int64(1)) + 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() @@ -41,8 +41,8 @@ func TestConfig_SignedTextures(t *testing.T) { "value": "mocked textures base64" }, { - "name": "ely", - "value": "but why are you asking?" + "name": "chrly", + "value": "how do you tame a horse in Minecraft?" } ] }`, string(response)) @@ -54,10 +54,10 @@ func TestConfig_SignedTextures2(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, _, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{}) - wd.EXPECT().IncCounter("signed_textures.request", int64(1)) + 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() diff --git a/http/skin.go b/http/skin.go index 0c8e0eb..49531dc 100644 --- a/http/skin.go +++ b/http/skin.go @@ -18,7 +18,7 @@ func (cfg *Config) Skin(response http.ResponseWriter, request *http.Request) { return } - http.Redirect(response, request, buildElyUrl(rec.Url), 301) + http.Redirect(response, request, rec.Url, 301) } func (cfg *Config) SkinGET(response http.ResponseWriter, request *http.Request) { diff --git a/http/skin_test.go b/http/skin_test.go index 0f55cb7..1540171 100644 --- a/http/skin_test.go +++ b/http/skin_test.go @@ -7,8 +7,8 @@ import ( "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/model" + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/model" ) func TestConfig_Skin(t *testing.T) { @@ -17,10 +17,10 @@ func TestConfig_Skin(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, _, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - wd.EXPECT().IncCounter("skins.request", int64(1)) + 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() @@ -38,10 +38,10 @@ func TestConfig_Skin2(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, _, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"}) - wd.EXPECT().IncCounter("skins.request", int64(1)) + 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() @@ -59,11 +59,11 @@ func TestConfig_SkinGET(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, _, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - wd.EXPECT().IncCounter("skins.get_request", int64(1)) - wd.EXPECT().IncCounter("skins.request", int64(1)).Times(0) + 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() @@ -81,11 +81,11 @@ func TestConfig_SkinGET2(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, _, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"}) - wd.EXPECT().IncCounter("skins.get_request", int64(1)) - wd.EXPECT().IncCounter("skins.request", int64(1)).Times(0) + 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() @@ -112,6 +112,7 @@ func TestConfig_SkinGET3(t *testing.T) { func createSkinModel(username string, isSlim bool) *model.Skin { return &model.Skin{ + UserId: 1, Username: username, Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", SkinId: 1, diff --git a/http/textures.go b/http/textures.go index 4001b29..a07e0c8 100644 --- a/http/textures.go +++ b/http/textures.go @@ -11,7 +11,7 @@ import ( "github.com/gorilla/mux" - "elyby/minecraft-skinsystem/model" + "github.com/elyby/chrly/model" ) type texturesResponse struct { @@ -46,8 +46,6 @@ func (cfg *Config) Textures(response http.ResponseWriter, request *http.Request) skin.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png" skin.Hash = string(buildNonElyTexturesHash(username)) - } else { - skin.Url = buildElyUrl(skin.Url) } textures := texturesResponse{ diff --git a/http/textures_test.go b/http/textures_test.go index 97f6ac2..c4c879f 100644 --- a/http/textures_test.go +++ b/http/textures_test.go @@ -10,8 +10,8 @@ import ( "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/model" + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/model" ) func TestConfig_Textures(t *testing.T) { @@ -20,11 +20,11 @@ func TestConfig_Textures(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - capesRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"}) - wd.EXPECT().IncCounter("textures.request", int64(1)) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) + mocks.Capes.EXPECT().FindByUsername("mock_user").Return(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() @@ -49,11 +49,11 @@ func TestConfig_Textures2(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil) - capesRepo.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"}) - wd.EXPECT().IncCounter("textures.request", int64(1)) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil) + mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"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() @@ -81,13 +81,13 @@ func TestConfig_Textures3(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - capesRepo.EXPECT().FindByUsername("mock_user").Return(&model.Cape{ + 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) - wd.EXPECT().IncCounter("textures.request", int64(1)) + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil) w := httptest.NewRecorder() @@ -116,11 +116,11 @@ func TestConfig_Textures4(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - config, skinsRepo, capesRepo, wd := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - skinsRepo.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{}) - capesRepo.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{}) - wd.EXPECT().IncCounter("textures.request", int64(1)) + 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) } diff --git a/interfaces/api.go b/interfaces/api.go deleted file mode 100644 index 7b061ec..0000000 --- a/interfaces/api.go +++ /dev/null @@ -1,9 +0,0 @@ -package interfaces - -import ( - "elyby/minecraft-skinsystem/api/accounts" -) - -type AccountsAPI interface { - AccountInfo(attribute string, value string) (*accounts.AccountInfoResponse, error) -} diff --git a/interfaces/auth.go b/interfaces/auth.go new file mode 100644 index 0000000..3f645f7 --- /dev/null +++ b/interfaces/auth.go @@ -0,0 +1,7 @@ +package interfaces + +import "net/http" + +type AuthChecker interface { + Check(req *http.Request) error +} diff --git a/interfaces/mock_interfaces/mock_api.go b/interfaces/mock_interfaces/mock_api.go deleted file mode 100644 index 8339001..0000000 --- a/interfaces/mock_interfaces/mock_api.go +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: interfaces/api.go - -package mock_interfaces - -import ( - accounts "elyby/minecraft-skinsystem/api/accounts" - gomock "github.com/golang/mock/gomock" - reflect "reflect" -) - -// MockAccountsAPI is a mock of AccountsAPI interface -type MockAccountsAPI struct { - ctrl *gomock.Controller - recorder *MockAccountsAPIMockRecorder -} - -// MockAccountsAPIMockRecorder is the mock recorder for MockAccountsAPI -type MockAccountsAPIMockRecorder struct { - mock *MockAccountsAPI -} - -// NewMockAccountsAPI creates a new mock instance -func NewMockAccountsAPI(ctrl *gomock.Controller) *MockAccountsAPI { - mock := &MockAccountsAPI{ctrl: ctrl} - mock.recorder = &MockAccountsAPIMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use -func (_m *MockAccountsAPI) EXPECT() *MockAccountsAPIMockRecorder { - return _m.recorder -} - -// AccountInfo mocks base method -func (_m *MockAccountsAPI) AccountInfo(attribute string, value string) (*accounts.AccountInfoResponse, error) { - ret := _m.ctrl.Call(_m, "AccountInfo", attribute, value) - ret0, _ := ret[0].(*accounts.AccountInfoResponse) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// AccountInfo indicates an expected call of AccountInfo -func (_mr *MockAccountsAPIMockRecorder) AccountInfo(arg0, arg1 interface{}) *gomock.Call { - return _mr.mock.ctrl.RecordCallWithMethodType(_mr.mock, "AccountInfo", reflect.TypeOf((*MockAccountsAPI)(nil).AccountInfo), arg0, arg1) -} diff --git a/interfaces/mock_interfaces/mock_auth.go b/interfaces/mock_interfaces/mock_auth.go new file mode 100644 index 0000000..6b78454 --- /dev/null +++ b/interfaces/mock_interfaces/mock_auth.go @@ -0,0 +1,45 @@ +// 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) +} diff --git a/interfaces/mock_interfaces/mock_interfaces.go b/interfaces/mock_interfaces/mock_interfaces.go index 72b1a52..846ec92 100644 --- a/interfaces/mock_interfaces/mock_interfaces.go +++ b/interfaces/mock_interfaces/mock_interfaces.go @@ -4,7 +4,7 @@ package mock_interfaces import ( - model "elyby/minecraft-skinsystem/model" + model "github.com/elyby/chrly/model" gomock "github.com/golang/mock/gomock" reflect "reflect" ) @@ -70,6 +70,30 @@ 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 diff --git a/interfaces/repositories.go b/interfaces/repositories.go index 94164e9..05d2df5 100644 --- a/interfaces/repositories.go +++ b/interfaces/repositories.go @@ -1,13 +1,15 @@ package interfaces import ( - "elyby/minecraft-skinsystem/model" + "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 { diff --git a/logger/receivers/sentry/receiver.go b/logger/receivers/sentry/receiver.go deleted file mode 100644 index 9c0b872..0000000 --- a/logger/receivers/sentry/receiver.go +++ /dev/null @@ -1,132 +0,0 @@ -package sentry - -import ( - "fmt" - - "github.com/getsentry/raven-go" - "github.com/mono83/slf" - "github.com/mono83/slf/filters" -) - -// Config holds information for filtered receiver -type Config struct { - MinLevel string - ParamsWhiteList []string - ParamsBlackList []string -} - -// NewReceiver allows you to create a new receiver in the Sentry -// using the fastest and easiest way. -// The Config parameter can be passed as nil if you do not need additional filtration. -func NewReceiver(dsn string, cfg *Config) (slf.Receiver, error) { - client, err := raven.New(dsn) - if err != nil { - return nil, err - } - - return NewReceiverWithCustomRaven(client, cfg) -} - -// NewReceiverWithCustomRaven allows you to create a new receiver in the Sentry -// configuring raven.Client by yourself. This can be useful if you need to set -// additional parameters, such as release and environment, that will be sent -// with each Packet in the Sentry: -// -// client, err := raven.New("https://some:sentry@dsn.sentry.io/1") -// if err != nil { -// return nil, err -// } -// -// client.SetRelease("1.3.2") -// client.SetEnvironment("production") -// client.SetDefaultLoggerName("sentry-watchdog-receiver") -// -// sentryReceiver, err := sentry.NewReceiverWithCustomRaven(client, &sentry.Config{ -// MinLevel: "warn", -// }) -// -// The Config parameter allows you to add additional filtering, such as the minimum -// message level and the exclusion of private parameters. If you do not need additional -// filtering, nil can passed. -func NewReceiverWithCustomRaven(client *raven.Client, cfg *Config) (slf.Receiver, error) { - out, err := buildReceiverForClient(client) - if err != nil { - return nil, err - } - - if cfg == nil { - return out, nil - } - - // Resolving level - level, ok := slf.ParseType(cfg.MinLevel) - if !ok { - return nil, fmt.Errorf("Unknown level %s", cfg.MinLevel) - } - - if len(cfg.ParamsWhiteList) > 0 { - out.filter = slf.NewWhiteListParamsFilter(cfg.ParamsWhiteList) - } else { - out.filter = slf.NewBlackListParamsFilter(cfg.ParamsBlackList) - } - - return filters.MinLogLevel(level, out), nil -} - -func buildReceiverForClient(client *raven.Client) (*sentryLogReceiver, error) { - return &sentryLogReceiver{target: client, filter: slf.NewBlackListParamsFilter(nil)}, nil -} - -type sentryLogReceiver struct { - target *raven.Client - filter slf.ParamsFilter -} - -func (l sentryLogReceiver) Receive(p slf.Event) { - if !p.IsLog() { - return - } - - pkt := raven.NewPacket( - slf.ReplacePlaceholders(p.Content, p.Params, false), - // First 5 means, that first N elements will be skipped before actual app trace - // This is needed to exclude watchdog calls from stack trace - raven.NewStacktrace(5, 5, []string{}), - ) - - if len(p.Params) > 0 { - shownParams := l.filter(p.Params) - for _, param := range shownParams { - value := param.GetRaw() - if e, ok := value.(error); ok && e != nil { - value = e.Error() - } - - pkt.Extra[param.GetKey()] = value - } - } - - pkt.Level = convertType(p.Type) - pkt.Timestamp = raven.Timestamp(p.Time) - - l.target.Capture(pkt, map[string]string{}) -} - -func convertType(wdType byte) raven.Severity { - switch wdType { - case slf.TypeTrace: - case slf.TypeDebug: - return raven.DEBUG - case slf.TypeInfo: - return raven.INFO - case slf.TypeWarning: - return raven.WARNING - case slf.TypeError: - return raven.ERROR - case slf.TypeAlert: - case slf.TypeEmergency: - return raven.FATAL - } - - panic("Unknown wd type " + string(wdType)) -} diff --git a/main.go b/main.go index 5640a87..1a95996 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,7 @@ package main import ( "runtime" - "elyby/minecraft-skinsystem/cmd" + "github.com/elyby/chrly/cmd" ) func main() { diff --git a/script/mocks b/script/mocks new file mode 100755 index 0000000..66a61ef --- /dev/null +++ b/script/mocks @@ -0,0 +1,4 @@ +#!/bin/sh + +mockgen -source=interfaces/repositories.go -destination=interfaces/mock_interfaces/mock_interfaces.go +mockgen -source=interfaces/auth.go -destination=interfaces/mock_interfaces/mock_auth.go diff --git a/worker/worder_test.go b/worker/worder_test.go deleted file mode 100644 index cc2f7b8..0000000 --- a/worker/worder_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package worker - -import ( - "errors" - "testing" - - "github.com/golang/mock/gomock" - testify "github.com/stretchr/testify/assert" - - "elyby/minecraft-skinsystem/api/accounts" - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/interfaces/mock_interfaces" - "elyby/minecraft-skinsystem/interfaces/mock_wd" - "elyby/minecraft-skinsystem/model" -) - -func TestServices_HandleChangeUsername(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - services, skinRepo, _, wd := setupMocks(ctrl) - - resultModel := createSourceModel() - resultModel.Username = "new_username" - - // Запись о скине существует, никаких осложнений - skinRepo.EXPECT().FindByUserId(1).Return(createSourceModel(), nil) - skinRepo.EXPECT().Save(resultModel) - wd.EXPECT().IncCounter("worker.change_username", int64(1)) - - assert.True(services.HandleChangeUsername(&UsernameChanged{ - AccountId: 1, - OldUsername: "mock_user", - NewUsername: "new_username", - })) - - // Событие с пустым ником, т.е это регистрация, так что нужно создать запись о скине - skinRepo.EXPECT().FindByUserId(1).Times(0) - skinRepo.EXPECT().Save(&model.Skin{UserId: 1, Username: "new_mock"}) - wd.EXPECT().IncCounter("worker.change_username", int64(1)) - wd.EXPECT().IncCounter("worker.change_username_empty_old_username", int64(1)) - - assert.True(services.HandleChangeUsername(&UsernameChanged{ - AccountId: 1, - OldUsername: "", - NewUsername: "new_mock", - })) - - // В базе системы скинов нет записи об указанном пользователе, так что её нужно восстановить - skinRepo.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{}) - skinRepo.EXPECT().Save(&model.Skin{UserId: 1, Username: "new_mock2"}) - wd.EXPECT().IncCounter("worker.change_username", int64(1)) - wd.EXPECT().IncCounter("worker.change_username_id_not_found", int64(1)) - wd.EXPECT().Info("Cannot find user id :accountId. Trying to search.", gomock.Any()) - - assert.True(services.HandleChangeUsername(&UsernameChanged{ - AccountId: 1, - OldUsername: "mock_user", - NewUsername: "new_mock2", - })) - - // Репозиторий вернул неожиданную ошибку - skinRepo.EXPECT().FindByUserId(1).Return(nil, errors.New("mock error")) - skinRepo.EXPECT().Save(&model.Skin{UserId: 1, Username: "new_mock2"}) - wd.EXPECT().IncCounter("worker.change_username", int64(1)) - wd.EXPECT().IncCounter("worker.change_username_id_not_found", int64(1)) - wd.EXPECT().Info("Cannot find user id :accountId. Trying to search.", gomock.Any()) - wd.EXPECT().Error("Unknown error when requesting a skin from the repository: :err", gomock.Any()) - - assert.True(services.HandleChangeUsername(&UsernameChanged{ - AccountId: 1, - OldUsername: "mock_user", - NewUsername: "new_mock2", - })) -} - -func TestServices_HandleSkinChanged(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - services, skinRepo, accountsAPI, wd := setupMocks(ctrl) - - event := &SkinChanged{ - AccountId: 1, - Uuid: "cdb907ce-84f4-4c38-801d-1e287dca2623", - SkinId: 2, - OldSkinId: 1, - Hash: "f76caa016e07267a05b7daf9ebc7419c", - Is1_8: true, - IsSlim: false, - Url: "http://ely.by/minecraft/skins/69c6740d2993e5d6f6a7fc92420efc29.png", - MojangTextures: "new mocked textures base64", - MojangSignature: "new mocked signature", - } - - resultModel := createSourceModel() - resultModel.SkinId = event.SkinId - resultModel.Hash = event.Hash - resultModel.Is1_8 = event.Is1_8 - resultModel.IsSlim = event.IsSlim - resultModel.Url = event.Url - resultModel.MojangTextures = event.MojangTextures - resultModel.MojangSignature = event.MojangSignature - - // Запись о скине существует, никаких осложнений - skinRepo.EXPECT().FindByUserId(1).Return(createSourceModel(), nil) - skinRepo.EXPECT().Save(resultModel) - wd.EXPECT().IncCounter("worker.skin_changed", int64(1)) - - assert.True(services.HandleSkinChanged(event)) - - // Записи о скине не существует, она должна быть восстановлена - skinRepo.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{"mock_user"}) - skinRepo.EXPECT().Save(resultModel) - accountsAPI.EXPECT().AccountInfo("id", "1").Return(&accounts.AccountInfoResponse{ - Id: 1, - Username: "mock_user", - Uuid: "cdb907ce-84f4-4c38-801d-1e287dca2623", - Email: "mock-user@ely.by", - }, nil) - wd.EXPECT().IncCounter("worker.skin_changed", int64(1)) - wd.EXPECT().IncCounter("worker.skin_changed_id_not_found", int64(1)) - wd.EXPECT().IncCounter("worker.skin_changed_id_restored", int64(1)) - wd.EXPECT().Info("Cannot find user id :accountId. Trying to search.", gomock.Any()) - wd.EXPECT().Info("User info successfully restored.") - - assert.True(services.HandleSkinChanged(event)) - - // Записи о скине не существует, и Ely.by Accounts internal API не знает о таком пользователе - skinRepo.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{"mock_user"}) - accountsAPI.EXPECT().AccountInfo("id", "1").Return(nil, &accounts.NotFoundResponse{}) - wd.EXPECT().IncCounter("worker.skin_changed", int64(1)) - wd.EXPECT().IncCounter("worker.skin_changed_id_not_found", int64(1)) - wd.EXPECT().IncCounter("worker.skin_changed_id_not_restored", int64(1)) - wd.EXPECT().Info("Cannot find user id :accountId. Trying to search.", gomock.Any()) - wd.EXPECT().Error("Cannot restore user info for :accountId: :err", gomock.Any(), gomock.Any()) - - assert.True(services.HandleSkinChanged(event)) - - // Репозиторий скинов вернул неизвестную ошибку, и Ely.by Accounts internal API не знает о таком пользователе - skinRepo.EXPECT().FindByUserId(1).Return(nil, errors.New("mocked error")) - accountsAPI.EXPECT().AccountInfo("id", "1").Return(nil, &accounts.NotFoundResponse{}) - wd.EXPECT().IncCounter("worker.skin_changed", int64(1)) - wd.EXPECT().IncCounter("worker.skin_changed_id_not_found", int64(1)) - wd.EXPECT().IncCounter("worker.skin_changed_id_not_restored", int64(1)) - wd.EXPECT().Error("Unknown error when requesting a skin from the repository: :err", gomock.Any()) - wd.EXPECT().Info("Cannot find user id :accountId. Trying to search.", gomock.Any()) - wd.EXPECT().Error("Cannot restore user info for :accountId: :err", gomock.Any(), gomock.Any()) - - assert.True(services.HandleSkinChanged(event)) -} - -func createSourceModel() *model.Skin { - return &model.Skin{ - UserId: 1, - Uuid: "cdb907ce-84f4-4c38-801d-1e287dca2623", - Username: "mock_user", - SkinId: 1, - Url: "http://ely.by/minecraft/skins/3a345c701f473ac08c8c5b8ecb58ecf3.png", - Is1_8: false, - IsSlim: false, - Hash: "3a345c701f473ac08c8c5b8ecb58ecf3", - MojangTextures: "mocked textures base64", - MojangSignature: "mocked signature", - } -} - -func setupMocks(ctrl *gomock.Controller) ( - *Services, - *mock_interfaces.MockSkinsRepository, - *mock_interfaces.MockAccountsAPI, - *mock_wd.MockWatchdog, -) { - skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl) - accountApi := mock_interfaces.NewMockAccountsAPI(ctrl) - wd := mock_wd.NewMockWatchdog(ctrl) - - return &Services{ - SkinsRepo: skinsRepo, - AccountsAPI: accountApi, - Logger: wd, - }, skinsRepo, accountApi, wd -} diff --git a/worker/worker.go b/worker/worker.go deleted file mode 100644 index 19702ad..0000000 --- a/worker/worker.go +++ /dev/null @@ -1,220 +0,0 @@ -package worker - -import ( - "encoding/json" - "strconv" - - "github.com/assembla/cony" - "github.com/mono83/slf/wd" - "github.com/streadway/amqp" - - "elyby/minecraft-skinsystem/db" - "elyby/minecraft-skinsystem/interfaces" - "elyby/minecraft-skinsystem/model" -) - -type Services struct { - AmqpClient *cony.Client - SkinsRepo interfaces.SkinsRepository - AccountsAPI interfaces.AccountsAPI - Logger wd.Watchdog -} - -type UsernameChanged struct { - AccountId int `json:"accountId"` - OldUsername string `json:"oldUsername"` - NewUsername string `json:"newUsername"` -} - -type SkinChanged struct { - AccountId int `json:"userId"` - Uuid string `json:"uuid"` - SkinId int `json:"skinId"` - OldSkinId int `json:"oldSkinId"` - Hash string `json:"hash"` - Is1_8 bool `json:"is1_8"` - IsSlim bool `json:"isSlim"` - Url string `json:"url"` - MojangTextures string `json:"mojangTextures"` - MojangSignature string `json:"mojangSignature"` -} - -const exchangeName string = "events" -const queueName string = "skinsystem-accounts-events" - -func (service *Services) Run() error { - clientErrs, consumerErrs, deliveryChannel := setupClient(service.AmqpClient) - shouldReturnError := true - - for service.AmqpClient.Loop() { - select { - case msg := <-deliveryChannel: - shouldReturnError = false - service.HandleDelivery(&msg) - case err := <-consumerErrs: - if shouldReturnError { - return err - } - - service.Logger.Error("Consume error: :err", wd.ErrParam(err)) - case err := <-clientErrs: - if shouldReturnError { - return err - } - - service.Logger.Error("Client error: :err", wd.ErrParam(err)) - } - } - - return nil -} - -func (service *Services) HandleDelivery(delivery *amqp.Delivery) { - service.Logger.Debug("Incoming message with routing key " + delivery.RoutingKey) - var result bool = true - switch delivery.RoutingKey { - case "accounts.username-changed": - var event *UsernameChanged - json.Unmarshal(delivery.Body, &event) - result = service.HandleChangeUsername(event) - case "accounts.skin-changed": - var event *SkinChanged - json.Unmarshal(delivery.Body, &event) - result = service.HandleSkinChanged(event) - default: - service.Logger.Info("Unknown delivery with routing key " + delivery.RoutingKey) - delivery.Ack(false) - return - } - - if result { - delivery.Ack(false) - } else { - delivery.Reject(true) - } -} - -func (service *Services) HandleChangeUsername(event *UsernameChanged) bool { - service.Logger.IncCounter("worker.change_username", 1) - if event.OldUsername == "" { - service.Logger.IncCounter("worker.change_username_empty_old_username", 1) - record := &model.Skin{ - UserId: event.AccountId, - Username: event.NewUsername, - } - - service.SkinsRepo.Save(record) - - return true - } - - record, err := service.SkinsRepo.FindByUserId(event.AccountId) - if err != nil { - service.Logger.Info("Cannot find user id :accountId. Trying to search.", wd.IntParam("accountId", event.AccountId)) - if _, isSkinNotFound := err.(*db.SkinNotFoundError); !isSkinNotFound { - service.Logger.Error("Unknown error when requesting a skin from the repository: :err", wd.ErrParam(err)) - } - - service.Logger.IncCounter("worker.change_username_id_not_found", 1) - record = &model.Skin{ - UserId: event.AccountId, - } - } - - record.Username = event.NewUsername - service.SkinsRepo.Save(record) - - return true -} - -// TODO: возможно стоит добавить проверку на совпадение id аккаунтов -func (service *Services) HandleSkinChanged(event *SkinChanged) bool { - service.Logger.IncCounter("worker.skin_changed", 1) - var record *model.Skin - record, err := service.SkinsRepo.FindByUserId(event.AccountId) - if err != nil { - if _, isSkinNotFound := err.(*db.SkinNotFoundError); !isSkinNotFound { - service.Logger.Error("Unknown error when requesting a skin from the repository: :err", wd.ErrParam(err)) - } - - service.Logger.IncCounter("worker.skin_changed_id_not_found", 1) - service.Logger.Info("Cannot find user id :accountId. Trying to search.", wd.IntParam("accountId", event.AccountId)) - response, err := service.AccountsAPI.AccountInfo("id", strconv.Itoa(event.AccountId)) - if err != nil { - service.Logger.IncCounter("worker.skin_changed_id_not_restored", 1) - service.Logger.Error( - "Cannot restore user info for :accountId: :err", - wd.IntParam("accountId", event.AccountId), - wd.ErrParam(err), - ) - - return true - } - - service.Logger.IncCounter("worker.skin_changed_id_restored", 1) - service.Logger.Info("User info successfully restored.") - - record = &model.Skin{ - UserId: response.Id, - Username: response.Username, - } - } - - record.Uuid = event.Uuid - record.SkinId = event.SkinId - record.Hash = event.Hash - record.Is1_8 = event.Is1_8 - record.IsSlim = event.IsSlim - record.Url = event.Url - record.MojangTextures = event.MojangTextures - record.MojangSignature = event.MojangSignature - - service.SkinsRepo.Save(record) - - return true -} - -func setupClient(client *cony.Client) (<-chan error, <-chan error, <-chan amqp.Delivery ) { - exchange := cony.Exchange{ - Name: exchangeName, - Kind: "topic", - Durable: true, - AutoDelete: false, - } - - queue := &cony.Queue{ - Name: queueName, - Durable: true, - AutoDelete: false, - Exclusive: false, - } - - usernameEventBinding := cony.Binding{ - Exchange: exchange, - Queue: queue, - Key: "accounts.username-changed", - } - - skinEventBinding := cony.Binding{ - Exchange: exchange, - Queue: queue, - Key: "accounts.skin-changed", - } - - declarations := []cony.Declaration{ - cony.DeclareExchange(exchange), - cony.DeclareQueue(queue), - cony.DeclareBinding(usernameEventBinding), - cony.DeclareBinding(skinEventBinding), - } - - client.Declare(declarations) - - consumer := cony.NewConsumer(queue, - cony.Qos(10), - cony.AutoTag(), - ) - client.Consume(consumer) - - return client.Errors(), consumer.Errors(), consumer.Deliveries() -}