Compare commits

...

110 Commits

Author SHA1 Message Date
ErickSkrauch
63df092973 Merge branch 'develop' 2017-09-11 17:00:22 +03:00
ErickSkrauch
378643623b Исправлена ошибка, которая возвращается, если в бд не найдено записи о скине 2017-09-11 16:49:08 +03:00
ErickSkrauch
e33b86b809 Merge branch 'develop' 2017-09-11 14:25:15 +03:00
ErickSkrauch
80fa307915 Обновлён .gitlab-ci: переименованы ENV перменные в соответствии с 9 версией GitLab
Исправлен вызов компилятора для "зашивания" версии при сборке
2017-09-11 14:17:28 +03:00
ErickSkrauch
2e9520db89 Добавлена команда version для отображения версии 2017-09-11 14:16:25 +03:00
ErickSkrauch
74564b4747 Fixes SKINSYSTEM-3 2017-09-11 13:54:11 +03:00
ErickSkrauch
18909776a8 Merge branch 'develop' 2017-09-05 01:05:17 +03:00
ErickSkrauch
d1b1f22a93 Merge branch 'v4' 2017-09-05 01:04:41 +03:00
ErickSkrauch
cb928a3918 Исправлен volume для worker в docker-compose под production [skip ci] 2017-09-05 00:57:40 +03:00
ErickSkrauch
d9aeaba627 Компилируем на golang:1.9-alpine image 2017-09-04 23:56:10 +03:00
ErickSkrauch
645f6ac694 Для сборки проекта теперь используется Go 1.9 2017-09-04 20:25:32 +03:00
ErickSkrauch
eab7c6ecaa Все Docker штуки опущены в директорию docker.
Production Docker контейнер теперь использует alpine linux вместо пустого scratch
В production Docker контейнер добавлен docker-entrypoint.sh, который автоматически создаёт конфиг по умолчанию.
2017-09-04 20:24:55 +03:00
ErickSkrauch
ac714de8df Логгер в консоль теперь не добавляет метку в конец строки, а также выводит время в более коротком формате 2017-09-03 22:54:46 +03:00
ErickSkrauch
8007b082d6 Реализовано автоматическое восстановление соединения с AMQP 2017-09-03 22:45:38 +03:00
ErickSkrauch
9cb6502f9c Модели amqp событий перенесены непосредственно в компонент amqp worker 2017-09-03 21:41:40 +03:00
ErickSkrauch
76a3f3ad26 rabbitmq images заменены на alpine версии 2017-09-03 21:28:17 +03:00
ErickSkrauch
bdd7c5e15e Обновлены docker-compose файлы
Добавлен config.dist.yml
Обновлено README проекта (наконец-то нормально описание!)
Файл конфигурации теперь автоматически ищется в директории проекта.
2017-09-03 00:09:11 +03:00
ErickSkrauch
340b24d862 Добавлена генерация версии при сборке проекта 2017-09-02 21:37:16 +03:00
ErickSkrauch
cf99a0eab2 Добавлена интеграция с Sentry 2017-08-27 18:10:03 +03:00
ErickSkrauch
fb4ae46e29 На этап сборки docker контейнера возвращено использование репозитория 2017-08-24 15:10:30 +03:00
ErickSkrauch
971155485b Игнорируем возможную неудачу команды docker rmi на этапе cleanup 2017-08-24 15:04:52 +03:00
ErickSkrauch
9ee3e93042 Обновлёна логика построения production image, используем только scratch, без alpine linux 2017-08-24 14:57:03 +03:00
ErickSkrauch
6128c56a0c Добавлен вызов runtime.GOMAXPROCS()
Обновлены зависимости
2017-08-23 00:01:58 +03:00
ErickSkrauch
a2e3d28580 Добавлены скрипты для тестирования и подсчёта общего coverage 2017-08-21 18:45:27 +03:00
ErickSkrauch
fecfa9c4e8 Оттестирован функционал пакета worker 2017-08-21 15:37:15 +03:00
ErickSkrauch
04714543b8 Реорганизация пакета daemon в http.
Упразднён пакет utils.
Удалён обработчик minecraft.php (legacy с самого-самого начала Ely.by)
Добавлены тесты для всех api-запросов.
2017-08-20 01:22:42 +03:00
ErickSkrauch
ec461efe34 Добавлена логика автоматического рефреша API токена при его истечении 2017-08-18 17:48:29 +03:00
ErickSkrauch
eec6b384b7 Тестирование включено в CI 2017-08-18 02:03:18 +03:00
ErickSkrauch
4734bfd93c Восстановлена логика для доступна к internal API Accounts Ely.by 2017-08-18 01:08:08 +03:00
ErickSkrauch
b1dbee2310 repositories package переименован в interfaces 2017-08-18 00:50:23 +03:00
ErickSkrauch
78917a70d3 Частично восстановлена логика AMQP воркера 2017-08-17 02:47:35 +03:00
ErickSkrauch
4bf146dd43 Восстановлен логгинг метрик в statsd, если таковой указан в конфигурации 2017-08-16 15:23:03 +03:00
ErickSkrauch
06b8e88346 Реализовано автоматическое восстановление соединения с redis 2017-08-15 01:03:02 +03:00
ErickSkrauch
4945b3f984 Исправлен Dockerfile 2017-08-15 00:44:27 +03:00
ErickSkrauch
359aef4b40 Миграция с glide на dep для управления зависимостями 2017-08-15 00:43:56 +03:00
ErickSkrauch
b159cd327c Подчищены команды в cmd 2017-08-15 00:43:31 +03:00
ErickSkrauch
b99697d26e Попытка сделать фабрики репозиториев для абстрактных хранилищ данных.
Добавлено чтение конфигурации из файла.
2017-08-14 21:06:22 +03:00
ErickSkrauch
d51c358ef6 Имплементации репозиториев теперь хранятся в том же пакете, что и базовое описание фабрики репозитория 2017-08-10 03:14:28 +03:00
ErickSkrauch
d9629b5e83 Возвращаем ошибки по ссылке в реализациях репозиториев 2017-08-10 03:00:02 +03:00
ErickSkrauch
428bedf301 Entities в model, repositories в repositories 2017-08-09 19:19:46 +03:00
ErickSkrauch
11a7570f51 Учитываем пустой input для методов FindByUsername 2017-08-09 19:11:53 +03:00
ErickSkrauch
676ba03c37 Применены рекомендации от index0h 2017-07-02 03:35:38 +03:00
ErickSkrauch
07903cf9c8 Переработка структуры проекта 2017-06-30 18:40:25 +03:00
ErickSkrauch
e090d04dc7 Go обновлён до 1.9
Перешли на использование менеджера зависимостей glide
2017-06-28 13:32:20 +03:00
ErickSkrauch
a993c1d157 Реализовано сжатие значений в redis 2017-06-19 02:20:38 +03:00
ErickSkrauch
a661f9aac3 Merge branch 'develop' 2017-06-17 01:30:58 +03:00
ErickSkrauch
9ffdf99b77 Образ redis заменён на 3.2-32bit для экономии памяти 2017-06-17 01:17:02 +03:00
ErickSkrauch
ad35872fc1 Добавлено логирование запроса получения токена авторизации 2017-06-17 01:16:34 +03:00
ErickSkrauch
a8d8fffaa5 Исправлен адрес для API запросов 2017-06-17 01:16:08 +03:00
ErickSkrauch
0d41f0c347 Merge branch 'develop' 2017-04-13 14:20:36 +03:00
ErickSkrauch
b22f0551fa Не добавляем домен к ссылке на скин, если скин уже имеет домен 2017-04-13 14:20:04 +03:00
ErickSkrauch
1a906cfc09 Merge branch 'develop' 2017-04-10 20:50:25 +03:00
ErickSkrauch
f610667aa5 Revert statsd config 2017-04-10 20:47:45 +03:00
ErickSkrauch
8b51c1bd0c Merge branch 'develop' 2017-04-10 20:38:35 +03:00
ErickSkrauch
cbe940f8ec Конфиг Accounts API вынесен в параметры окружения 2017-04-10 20:28:47 +03:00
ErickSkrauch
8693673a71 Добавлена поддержка восстановления информации об аккаунте, если по какой-то причине её не удалось найти в хранилище 2017-04-10 14:53:26 +03:00
ErickSkrauch
73205648d2 Merge branch 'develop' 2017-04-04 18:54:22 +03:00
ErickSkrauch
3d73cc9402 Добавлено Ely property 2017-04-01 12:46:25 +03:00
ErickSkrauch
39f5ec5bee Учитываем ситуацию, когда скин есть, а просчитанной текстуры - нет 2017-04-01 12:22:48 +03:00
ErickSkrauch
e652691b29 Добавлена поддержка отображения подписанных текстур 2017-04-01 12:18:57 +03:00
ErickSkrauch
d3b4bee3b0 Обновлён docker-composer.base.yml 2017-03-25 16:48:47 +03:00
ErickSkrauch
ae50e90ea7 Merge branch 'statsd' into develop 2017-03-25 16:38:09 +03:00
ErickSkrauch
c74151c558 Переименованы некоторые параметры для statsd 2017-03-25 16:36:37 +03:00
ErickSkrauch
445bd18fbc Merge branch 'develop' into statsd 2017-03-25 15:56:07 +03:00
ErickSkrauch
6a881a62e3 Доработана конфигурация взаимодействия со statsd 2017-03-25 15:55:57 +03:00
ErickSkrauch
201a257d69 Исправлены вызовы IncCounter для обработчиков задач из amqp 2017-03-25 15:54:17 +03:00
ErickSkrauch
5d46094643 Удалён обработчик для запроса setSkin 2016-12-03 02:08:00 +03:00
ErickSkrauch
1694403c79 Добавлен gitlab-ci 2016-12-03 02:05:23 +03:00
ErickSkrauch
66c61dc3cd Добавлено логгирование метрик для системы скинов 2016-12-03 01:57:55 +03:00
ErickSkrauch
a0d940f8cd Попытка внедрить statsd 2016-11-15 14:15:16 +03:00
ErickSkrauch
58a1c6ec33 Merge branch 'develop' 2016-11-15 13:42:30 +03:00
ErickSkrauch
34179ae1fe Исправлена опечатка в ссылке на лицо 2016-11-15 13:41:52 +03:00
ErickSkrauch
6a54af62aa Merge branch 'develop' 2016-11-02 16:55:49 +03:00
ErickSkrauch
e05c5f200c Поддержка env для подключения к внешним контейнерам 2016-10-12 20:43:10 +03:00
ErickSkrauch
9c4930a0be Используем alpine image 2016-10-11 18:20:17 +03:00
ErickSkrauch
aab7ba9517 Фикс группировки импортов 2016-10-11 18:01:33 +03:00
ErickSkrauch
dea674f52e Merge branch 'capes_support' into develop
# Conflicts:
#	docker-compose.base.yml
2016-10-11 17:56:53 +03:00
ErickSkrauch
e8a7008e11 Реорганизация контейнеров в compose файлах 2016-10-06 00:38:53 +03:00
ErickSkrauch
9467911025 Загрузка плаща вынесена в отдельный метод, реализовано отображение ссылки на плащ в запросах на текстуры 2016-09-22 19:32:00 +03:00
ErickSkrauch
a9acfb954f Биндим папку с плащами к контейнеру с приложением 2016-09-22 19:30:41 +03:00
ErickSkrauch
98b787fa99 Добавлен функционал проверки существования скина для роута /cloaks 2016-09-21 21:44:52 +03:00
ErickSkrauch
4bcd0495ed Откат изменения активного порта 2016-09-21 20:52:54 +03:00
ErickSkrauch
e03832b4e8 Добавлен роут для загрузки рендера лица скина 2016-09-21 20:52:28 +03:00
ErickSkrauch
0d6ca356d1 Merge branch 'rabbitmq_integration' into develop 2016-09-21 20:29:13 +03:00
ErickSkrauch
2477433dc9 Реализовано восстановление соединения с redis 2016-09-16 19:35:58 +03:00
ErickSkrauch
3e3ba296d5 Биндимся только к тому, что нам интересно 2016-09-15 01:29:08 +03:00
ErickSkrauch
e8bd90d8d9 Реализован функционал прослушивания RabbitMQ сообщений и соответствующие handlers для событий 2016-09-15 01:22:57 +03:00
ErickSkrauch
408d411846 Добавлен функционал сохранения id к username пользователя + метод Delete для SkinItem 2016-09-15 01:19:16 +03:00
ErickSkrauch
45007ba1c5 Merge branch 'develop' 2016-08-26 23:46:22 +03:00
ErickSkrauch
4bdab704a5 Удалено логирование ожидаемой ошибки с несуществованием скина в redis. 2016-08-26 23:45:55 +03:00
ErickSkrauch
89ea6e5ee8 Данные Redis опущены в свою папку 2016-08-26 22:05:29 +03:00
ErickSkrauch
24438fdedf Реорганизация compose файлов 2016-08-02 15:02:36 +03:00
ErickSkrauch
eeffd17ea9 Merge branch 'v3.0'
# Conflicts:
#	app.php
2016-08-02 14:20:45 +03:00
ErickSkrauch
8abb5f6bc5 Попытка вернуть пул соединений 2016-07-29 12:24:31 +03:00
ErickSkrauch
58c05533f3 Revert "Revert "Реализована страница 404 ошибки""
This reverts commit 22f80576bd.
2016-07-29 12:14:32 +03:00
ErickSkrauch
22f80576bd Revert "Реализована страница 404 ошибки"
This reverts commit c2d0cb93cb.
2016-07-29 09:56:25 +03:00
ErickSkrauch
c03021403a Исправлена ошибка, когда к скинам моянга приклеивался наш домен 2016-07-29 01:20:09 +03:00
ErickSkrauch
64bf7deb79 Корректировка под более-менее финальную версию протокола 2016-07-29 01:13:09 +03:00
ErickSkrauch
c2d0cb93cb Реализована страница 404 ошибки
Реализовано переподключение к Redis в случае, если соединение упадёт
2016-07-28 18:31:25 +03:00
ErickSkrauch
283f4e0e3f Отныне мы не используем пул соединений для редиса (Revert, прогнал тесты, убедился, что только хуже)
This reverts commit 915c465224.
2016-07-07 23:41:21 +03:00
ErickSkrauch
915c465224 Отныне мы используем пул соединений для редиса 2016-07-07 22:01:45 +03:00
ErickSkrauch
4da7a566f7 Добавлены обработчики для Legacy запросов 2016-07-07 13:10:39 +03:00
ErickSkrauch
e3f744ed10 Nginx удалён за своей ненадобностью 2016-07-07 00:46:32 +03:00
ErickSkrauch
2b8266b224 Добавлена установка значения GOMAXPROCS
Сервис роутера вынесен в глобальные
2016-07-07 00:43:42 +03:00
ErickSkrauch
3d65529d2e Добавлен обработчик NotFound
Исправлена опечатка в тесте
Разделена логика метода BuildNonElyTexturesHash
2016-07-06 14:45:14 +03:00
ErickSkrauch
c4cd95cddc Немного реструктуризации
Добавлен роут для смены скина
2016-07-06 01:25:05 +03:00
ErickSkrauch
87ca1191eb Добавлено простое Readme 2016-07-05 01:42:58 +03:00
ErickSkrauch
6a7cc9ae77 Проект разбит на более мелкие части 2016-07-05 01:28:09 +03:00
ErickSkrauch
baa1cd3010 Перенесена имплементация на Go, описано Docker compose окружение 2016-07-04 00:20:41 +03:00
ErickSkrauch
b38b78bd1e Зачистка проекта 2016-07-03 21:47:13 +03:00
63 changed files with 3823 additions and 287 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
# Игнорим данные, т.к. они не нужны для внутреннего содержимого этого контейнера
data
# Vendor так же не нужен
vendor

15
.gitignore vendored
View File

@@ -1,2 +1,15 @@
# IDEA
/.idea
/awstat
# Docker Compose file
/docker-compose.yml
/docker-compose.override.yml
# vendor
/vendor
# Cover output
.cover
# Local config
/config.yml

96
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,96 @@
# Предполагается, что между работой "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

View File

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

189
Gopkg.lock generated Normal file
View File

@@ -0,0 +1,189 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/assembla/cony"
packages = ["."]
revision = "dd62697b0adb9adfda8589520cb85f4cbc2361f1"
version = "v0.3.2"
[[projects]]
name = "github.com/certifi/gocertifi"
packages = ["."]
revision = "3fd9e1adb12b72d2f3f82191d49be9b93c69f67c"
version = "2017.07.27"
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
name = "github.com/fsnotify/fsnotify"
packages = ["."]
revision = "629574ca2a5df945712d3079857300b5e4da0236"
version = "v1.4.2"
[[projects]]
branch = "master"
name = "github.com/getsentry/raven-go"
packages = ["."]
revision = "d175f85701dfbf44cb0510114c9943e665e60907"
[[projects]]
name = "github.com/golang/mock"
packages = ["gomock"]
revision = "13f360950a79f5864a972c786a10a50e44b69541"
version = "v1.0.0"
[[projects]]
name = "github.com/gorilla/context"
packages = ["."]
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
version = "v1.1"
[[projects]]
name = "github.com/gorilla/mux"
packages = ["."]
revision = "bcd8bc72b08df0f70df986b97f95590779502d31"
version = "v1.4.0"
[[projects]]
branch = "master"
name = "github.com/hashicorp/hcl"
packages = [".","hcl/ast","hcl/parser","hcl/scanner","hcl/strconv","hcl/token","json/parser","json/scanner","json/token"]
revision = "8f6b1344a92ff8877cf24a5de9177bf7d0a2a187"
[[projects]]
name = "github.com/inconshreveable/mousetrap"
packages = ["."]
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
version = "v1.0"
[[projects]]
name = "github.com/magiconair/properties"
packages = ["."]
revision = "be5ece7dd465ab0765a9682137865547526d1dfb"
version = "v1.7.3"
[[projects]]
branch = "master"
name = "github.com/mediocregopher/radix.v2"
packages = ["cluster","pool","redis","util"]
revision = "d234cfb904a91daafa4e1f92599a893b349cc0c2"
[[projects]]
branch = "master"
name = "github.com/mitchellh/mapstructure"
packages = ["."]
revision = "d0303fe809921458f417bcf828397a65db30a7e4"
[[projects]]
branch = "master"
name = "github.com/mono83/slf"
packages = [".","filters","params","rays","recievers","recievers/ansi","recievers/statsd","wd"]
revision = "8188a95c8d6b74c43953abb38b8bd6fdbc412ff5"
[[projects]]
branch = "master"
name = "github.com/mono83/udpwriter"
packages = ["."]
revision = "a064bd7e3acfda563ea680b913b9ef24b7a73e15"
[[projects]]
name = "github.com/pelletier/go-buffruneio"
packages = ["."]
revision = "c37440a7cf42ac63b919c752ca73a85067e05992"
version = "v0.2.0"
[[projects]]
name = "github.com/pelletier/go-toml"
packages = ["."]
revision = "5ccdfb18c776b740aecaf085c4d9a2779199c279"
version = "v1.0.0"
[[projects]]
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
branch = "master"
name = "github.com/spf13/afero"
packages = [".","mem"]
revision = "ee1bd8ee15a1306d1f9201acc41ef39cd9f99a1b"
[[projects]]
name = "github.com/spf13/cast"
packages = ["."]
revision = "acbeb36b902d72a7a4c18e8f3241075e7ab763e4"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "github.com/spf13/cobra"
packages = ["."]
revision = "3c0b56b677e04926dfa835a1b3f11cd4f62f076e"
[[projects]]
branch = "master"
name = "github.com/spf13/jwalterweatherman"
packages = ["."]
revision = "12bd96e66386c1960ab0f74ced1362f66f552f7b"
[[projects]]
name = "github.com/spf13/pflag"
packages = ["."]
revision = "e57e3eeb33f795204c1ca35f56c44f83227c6e66"
version = "v1.0.0"
[[projects]]
name = "github.com/spf13/viper"
packages = ["."]
revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7"
version = "v1.0.0"
[[projects]]
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 = "master"
name = "golang.org/x/sys"
packages = ["unix"]
revision = "7ddbeae9ae08c6a06a59597f0c9edbc5ff2444ce"
[[projects]]
branch = "master"
name = "golang.org/x/text"
packages = ["internal/gen","internal/triegen","internal/ucd","transform","unicode/cldr","unicode/norm"]
revision = "bd91bbf73e9a4a801adbfb97133c992678533126"
[[projects]]
name = "gopkg.in/h2non/gock.v1"
packages = ["."]
revision = "84d599244901620fb3eb96473eb9e50619f69b47"
version = "v1.0.6"
[[projects]]
branch = "v2"
name = "gopkg.in/yaml.v2"
packages = ["."]
revision = "eb3733d160e74a9c7e442f435eb3bea458e1d19f"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "dd545fafc23f9b6429b5b679ad5c213c14c819f1e4ea381823acf338651122e1"
solver-name = "gps-cdcl"
solver-version = 1

38
Gopkg.toml Normal file
View File

@@ -0,0 +1,38 @@
ignored = ["elyby/minecraft-skinsystem"]
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.4.0"
[[constraint]]
name = "github.com/mediocregopher/radix.v2"
[[constraint]]
name = "github.com/mono83/slf"
[[constraint]]
name = "github.com/spf13/cobra"
[[constraint]]
name = "github.com/spf13/viper"
[[constraint]]
name = "github.com/getsentry/raven-go"
[[constraint]]
name = "github.com/assembla/cony"
version = "^0.3.2"
# Testing dependencies
[[constraint]]
name = "github.com/stretchr/testify"
version = "^1.1.4"
[[constraint]]
name = "github.com/golang/mock"
version = "^1.0.0"
[[constraint]]
name = "gopkg.in/h2non/gock.v1"
version = "^1.0.6"

74
README.md Normal file
View File

@@ -0,0 +1,74 @@
# Ely.by Minecraft Skinsystem
Реализация API системы скинов для Minecraft v4.
## Config
Конфигурация может задаваться посредством любого из перечисленных форматов файлов: JSON, TOML, YAML, HCL и
Java properties. Кроме того, параметры конфигурации могут перезаписываться доступными при запуске программы
ENV переменными.
> **Заметка**: ENV переменные именуются как KEY.SUBKEY.SUBSUBKEY, т.е. все символы должны быть заглавными,
а точки должны отделять уровень вложенности.
Пример файла конфигурации находится в [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
```
Web-приложение, amqp worker и все сопутствующие сервисы будут автоматически запущены. Данные из контейнеров
будут синхронизироваться в папку `data`.
## Разработка
Перво-наперво необходимо [установить последнюю версию Go](https://golang.org/doc/install) и сконфигурировать
переменную окружения GOPATH, а также установить инструмент контроля версий [dep](https://github.com/golang/dep).
Затем можно склонировать репозиторий хитрым способом, чтобы удовлетворить все прекрасные особенности Go:
```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
# Устанавливаем зависимости
dep ensure
```
Чтобы запустить проект достаточно написать `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` и поднимаем сервисы:
```sh
cp docker/docker-compose.dev.yml docker-compose.yml
docker-compose up -d
```
После этого `go run main.go serve` должен запустить web-сервер без дополнительной модификации файла конфигурации.

166
api/accounts/accounts.go Normal file
View File

@@ -0,0 +1,166 @@
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}
}
}

View File

@@ -0,0 +1,98 @@
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)
}

View File

@@ -0,0 +1,56 @@
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
}

View File

@@ -0,0 +1,242 @@
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)
}
}

98
app.php
View File

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

91
bootstrap/bootstrap.go Normal file
View File

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

67
cmd/amqpWorker.go Normal file
View File

@@ -0,0 +1,67 @@
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)
}

46
cmd/root.go Normal file
View File

@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var RootCmd = &cobra.Command{
Use: "",
Short: "Nothing here",
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := RootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
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())
}
}

58
cmd/serve.go Normal file
View File

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

23
cmd/version.go Normal file
View File

@@ -0,0 +1,23 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"elyby/minecraft-skinsystem/bootstrap"
"runtime"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show the Minecraft Skinsystem version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Version: %s\n", bootstrap.GetVersion())
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
func init() {
RootCmd.AddCommand(versionCmd)
}

51
config.dist.yml Normal file
View File

@@ -0,0 +1,51 @@
# 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"

View File

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

View File

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

View File

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

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

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

25
db/commons.go Normal file
View File

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

34
db/factory.go Normal file
View File

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

59
db/filesystem.go Normal file
View File

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

198
db/redis.go Normal file
View File

@@ -0,0 +1,198 @@
package db
import (
"bytes"
"compress/zlib"
"encoding/json"
"fmt"
"io"
"log"
"strings"
"time"
"github.com/mediocregopher/radix.v2/pool"
"github.com/mediocregopher/radix.v2/redis"
"github.com/mediocregopher/radix.v2/util"
"elyby/minecraft-skinsystem/interfaces"
"elyby/minecraft-skinsystem/model"
)
type RedisFactory struct {
Host string
Port int
PoolSize int
connection util.Cmder
}
func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) {
connection, err := f.getConnection()
if err != nil {
return nil, err
}
return &redisDb{connection}, nil
}
func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) {
panic("capes repository not supported for this storage type")
}
func (f RedisFactory) getConnection() (util.Cmder, error) {
if f.connection == nil {
if f.Host == "" {
return nil, &ParamRequired{"host"}
}
if f.Port == 0 {
return nil, &ParamRequired{"port"}
}
addr := fmt.Sprintf("%s:%d", f.Host, f.Port)
conn, err := createConnection(addr, f.PoolSize)
if err != nil {
return nil, err
}
f.connection = conn
go func() {
period := 5
for {
time.Sleep(time.Duration(period) * time.Second)
resp := f.connection.Cmd("PING")
if resp.Err == nil {
continue
}
log.Println("Redis not pinged. Try to reconnect")
conn, err := createConnection(addr, f.PoolSize)
if err != nil {
log.Printf("Cannot reconnect to redis: %v\n", err)
log.Printf("Waiting %d seconds to retry\n", period)
continue
}
f.connection = conn
log.Println("Reconnected")
}
}()
}
return f.connection, nil
}
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
}
const accountIdToUsernameKey string = "hash:username-to-account-id"
func (db *redisDb) FindByUsername(username string) (*model.Skin, error) {
if username == "" {
return nil, &SkinNotFoundError{username}
}
redisKey := buildKey(username)
response := db.conn.Cmd("GET", redisKey)
if response.IsType(redis.Nil) {
return nil, &SkinNotFoundError{username}
}
encodedResult, err := response.Bytes()
if err != nil {
return nil, err
}
result, err := zlibDecode(encodedResult)
if err != nil {
log.Println("Cannot uncompress zlib for key " + redisKey) // TODO: replace with valid error
return nil, err
}
var skin *model.Skin
err = json.Unmarshal(result, &skin)
if err != nil {
log.Println("Cannot decode record data for key" + redisKey) // TODO: replace with valid error
return nil, nil
}
skin.OldUsername = skin.Username
return skin, nil
}
func (db *redisDb) FindByUserId(id int) (*model.Skin, error) {
response := db.conn.Cmd("HGET", accountIdToUsernameKey, id)
if response.IsType(redis.Nil) {
return nil, &SkinNotFoundError{"unknown"}
}
username, _ := response.Str()
return db.FindByUsername(username)
}
func (db *redisDb) Save(skin *model.Skin) error {
conn := db.conn
if poolConn, isPool := conn.(*pool.Pool); isPool {
conn, _ = poolConn.Get()
}
conn.Cmd("MULTI")
// Если пользователь сменил ник, то мы должны удать его ключ
if skin.OldUsername != "" && skin.OldUsername != skin.Username {
conn.Cmd("DEL", buildKey(skin.OldUsername))
}
// Если это новая запись или если пользователь сменил ник, то обновляем значение в хэш-таблице
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("EXEC")
skin.OldUsername = skin.Username
return nil
}
func buildKey(username string) string {
return "username:" + strings.ToLower(username)
}
//noinspection GoUnusedFunction
func zlibEncode(str []byte) []byte {
var buff bytes.Buffer
writer := zlib.NewWriter(&buff)
writer.Write(str)
writer.Close()
return buff.Bytes()
}
func zlibDecode(bts []byte) ([]byte, error) {
buff := bytes.NewReader(bts)
reader, readError := zlib.NewReader(buff)
if readError != nil {
return nil, readError
}
resultBuffer := new(bytes.Buffer)
io.Copy(resultBuffer, reader)
reader.Close()
return resultBuffer.Bytes(), nil
}

13
docker/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
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"]

51
docker/config.dist.yml Normal file
View File

@@ -0,0 +1,51 @@
# 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

View File

@@ -0,0 +1,46 @@
# 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"

View File

@@ -0,0 +1,36 @@
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: /

15
docker/docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/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 "$@"

38
http/cape.go Normal file
View File

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

138
http/cape_test.go Normal file
View File

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

27
http/face.go Normal file
View File

@@ -0,0 +1,27 @@
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"
}

53
http/face_test.go Normal file
View File

@@ -0,0 +1,53 @@
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"))
}

91
http/http.go Normal file
View File

@@ -0,0 +1,91 @@
package http
import (
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/mono83/slf/wd"
"elyby/minecraft-skinsystem/interfaces"
)
type Config struct {
ListenSpec string
SkinsRepo interfaces.SkinsRepository
CapesRepo interfaces.CapesRepository
Logger wd.Watchdog
}
func (cfg *Config) Run() error {
cfg.Logger.Info(fmt.Sprintf("Starting, HTTP on: %s\n", cfg.ListenSpec))
listener, err := net.Listen("tcp", cfg.ListenSpec)
if err != nil {
return err
}
server := &http.Server{
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: cfg.CreateHandler(),
}
go server.Serve(listener)
s := waitForSignal()
cfg.Logger.Info(fmt.Sprintf("Got signal: %v, exiting.", s))
return nil
}
func (cfg *Config) CreateHandler() http.Handler {
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", cfg.Skin).Methods("GET")
router.HandleFunc("/cloaks/{username}", cfg.Cape).Methods("GET").Name("cloaks")
router.HandleFunc("/textures/{username}", cfg.Textures).Methods("GET")
router.HandleFunc("/textures/signed/{username}", cfg.SignedTextures).Methods("GET")
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")
// 404
router.NotFoundHandler = http.HandlerFunc(cfg.NotFound)
return router
}
func parseUsername(username string) string {
const suffix = ".png"
if strings.HasSuffix(username, suffix) {
username = strings.TrimSuffix(username, suffix)
}
return username
}
func 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)
return <-ch
}

40
http/http_test.go Normal file
View File

@@ -0,0 +1,40 @@
package http
import (
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"elyby/minecraft-skinsystem/interfaces/mock_interfaces"
"elyby/minecraft-skinsystem/interfaces/mock_wd"
)
func TestParseUsername(t *testing.T) {
assert := testify.New(t)
assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end")
assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end")
}
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.")
}
func setupMocks(ctrl *gomock.Controller) (
*Config,
*mock_interfaces.MockSkinsRepository,
*mock_interfaces.MockCapesRepository,
*mock_wd.MockWatchdog,
) {
skinsRepo := mock_interfaces.NewMockSkinsRepository(ctrl)
capesRepo := mock_interfaces.NewMockCapesRepository(ctrl)
wd := mock_wd.NewMockWatchdog(ctrl)
return &Config{
SkinsRepo: skinsRepo,
CapesRepo: capesRepo,
Logger: wd,
}, skinsRepo, capesRepo, wd
}

18
http/not_found.go Normal file
View File

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

28
http/not_found_test.go Normal file
View File

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

53
http/signed_textures.go Normal file
View File

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

View File

@@ -0,0 +1,71 @@
package http
import (
"io/ioutil"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
testify "github.com/stretchr/testify/assert"
"elyby/minecraft-skinsystem/db"
)
func TestConfig_SignedTextures(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("signed_textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(200, resp.StatusCode)
assert.Equal("application/json", resp.Header.Get("Content-Type"))
response, _ := ioutil.ReadAll(resp.Body)
assert.JSONEq(`{
"id": "0f657aa8bfbe415db7005750090d3af3",
"name": "mock_user",
"properties": [
{
"name": "textures",
"signature": "mocked signature",
"value": "mocked textures base64"
},
{
"name": "ely",
"value": "but why are you asking?"
}
]
}`, string(response))
}
func TestConfig_SignedTextures2(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{})
wd.EXPECT().IncCounter("signed_textures.request", int64(1))
req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil)
w := httptest.NewRecorder()
config.CreateHandler().ServeHTTP(w, req)
resp := w.Result()
assert.Equal(204, resp.StatusCode)
response, _ := ioutil.ReadAll(resp.Body)
assert.Equal("", string(response))
}

36
http/skin.go Normal file
View File

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

124
http/skin_test.go Normal file
View File

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

104
http/textures.go Normal file
View File

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

166
http/textures_test.go Normal file
View File

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

9
interfaces/api.go Normal file
View File

@@ -0,0 +1,9 @@
package interfaces
import (
"elyby/minecraft-skinsystem/api/accounts"
)
type AccountsAPI interface {
AccountInfo(attribute string, value string) (*accounts.AccountInfoResponse, error)
}

View File

@@ -0,0 +1,46 @@
// 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)
}

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
package interfaces
import (
"elyby/minecraft-skinsystem/model"
)
type SkinsRepository interface {
FindByUsername(username string) (*model.Skin, error)
FindByUserId(id int) (*model.Skin, error)
Save(skin *model.Skin) error
}
type CapesRepository interface {
FindByUsername(username string) (*model.Cape, error)
}

View File

@@ -0,0 +1,132 @@
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))
}

12
main.go Normal file
View File

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

9
model/cape.go Normal file
View File

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

15
model/skin.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

46
script/coverage Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/sh
# Based on https://github.com/mlafeldt/chef-runner/blob/34269dbb726c243dff9764007e7bd7f0fe9ee331/script/coverage
# Generate test coverage statistics for Go packages.
#
# Works around the fact that `go test -coverprofile` currently does not work
# with multiple packages, see https://code.google.com/p/go/issues/detail?id=6909
#
# Usage: script/coverage [--html]
#
# --html Additionally create HTML report and open it in browser
#
set -e
workdir=.cover
profile="$workdir/cover.out"
mode=count
generate_cover_data() {
rm -rf "$workdir"
mkdir "$workdir"
go test -i "$@" # compile dependencies first before serializing go test invocations
for pkg in "$@"; do
f="$workdir/$(echo $pkg | tr / -).cover"
go test -covermode="$mode" -coverprofile="$f" "$pkg"
done
echo "mode: $mode" >"$profile"
grep -h -v "^mode:" "$workdir"/*.cover >>"$profile"
}
show_cover_report() {
go tool cover -${1}="$profile"
}
generate_cover_data $(go list ./... | grep -v /vendor/)
show_cover_report func
case "$1" in
"")
;;
--html)
show_cover_report html ;;
*)
echo >&2 "error: invalid option: $1"; exit 1 ;;
esac

27
script/test Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/sh
# Based on https://github.com/mlafeldt/chef-runner/blob/34269dbb726c243dff9764007e7bd7f0fe9ee331/script/test
# Run package tests for a file/directory, or all tests if no argument is passed.
# Useful to e.g. execute package tests for the file currently open in Vim.
# Usage: script/test [path]
set -e
go_pkg_from_path() {
path=$1
if test -d "$path"; then
dir="$path"
else
dir=$(dirname "$path")
fi
(cd "$dir" && go list)
}
if test $# -gt 0; then
pkg=$(go_pkg_from_path "$1")
verbose=-v
else
pkg=$(go list ./... | grep -v /vendor/)
verbose=
fi
exec go test ${GOTESTOPTS:-$verbose} $pkg

187
worker/worder_test.go Normal file
View File

@@ -0,0 +1,187 @@
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
}

220
worker/worker.go Normal file
View File

@@ -0,0 +1,220 @@
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()
}