Compare commits

...

38 Commits

Author SHA1 Message Date
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
35 changed files with 868 additions and 287 deletions

2
.dockerignore Normal file
View File

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

6
.gitignore vendored
View File

@@ -1,2 +1,6 @@
# IDEA
/.idea
/awstat
# Docker Compose file
docker-compose.yml
docker-compose.override.yml

View File

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

21
Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.7-alpine
RUN apk add --no-cache git
RUN mkdir -p /go/src/elyby/minecraft-skinsystem \
/go/src/elyby/minecraft-skinsystem/data/capes \
&& ln -s /go/src/elyby/minecraft-skinsystem /go/src/app
WORKDIR /go/src/app
COPY ./minecraft-skinsystem.go /go/src/app/
COPY ./lib /go/src/app/lib
RUN go-wrapper download
RUN go-wrapper install
EXPOSE 80
VOLUME ["/go/src/app"]
CMD ["go-wrapper", "run"]

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# Это заготовка для нормального файла
Для настройки Dev-окружения нужно склонировать проект в удобное место,
за тем сделать символьную ссылку в свой GOPATH:
```sh
# Выполнять, находясь внутри директории репозитория
mkdir -p $GOPATH/src/elyby
ln -s $PWD $GOPATH/src/elyby/minecraft-skinsystem
```
Или можно склонировать репозиторий сразу в нужную локацию:
```sh
git clone git@bitbucket.org:elyby/minecraft-skinsystem.git $GOPATH/src/elyby/minecraft-skinsystem
```
Нужно скопировать правильный docker-compose файл для желаемого окружения:
```sh
cp docker-compose.dev.yml docker-compose.yml # dev env
cp docker-compose.prod.yml docker-compose.yml # prod env
```
И за тем всё это поднять:
```sh
docker-compose up -d
```
Если нужно пересобрать весь контейнер, то выполняем следующее:
```
docker-compose stop app # Останавливаем конейтнер, если он ещё работает
docker-compose rm -f app # Удаляем конейтнер
docker-compose build app # Запускаем билд по новой
docker-compose up -d app # Поднимаем свежесобранный контейнер обратно
```

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

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

13
docker-compose.base.yml Normal file
View File

@@ -0,0 +1,13 @@
version: '2'
services:
redis:
image: redis:3.0-alpine
volumes:
- ./data/redis:/data
rabbitmq:
image: rabbitmq:3.6
environment:
RABBITMQ_DEFAULT_USER: "ely-skinsystem-app"
RABBITMQ_DEFAULT_PASS: "ely-skinsystem-app-password"
RABBITMQ_DEFAULT_VHOST: "/ely"

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

@@ -0,0 +1,23 @@
version: '2'
services:
app:
build: .
image: registry.ely.by/elyby/skinsystem:latest
ports:
- "80:80"
volumes:
- ./:/go/src/app
command: ["go", "run", "minecraft-skinsystem.go"]
links:
- redis
- rabbitmq
redis:
extends:
file: docker-compose.base.yml
service: redis
rabbitmq:
extends:
file: docker-compose.base.yml
service: rabbitmq

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

@@ -0,0 +1,22 @@
version: '2'
services:
app:
image: registry.ely.by/elyby/skinsystem:latest
ports:
- "80:80"
links:
- redis
- rabbitmq
restart: always
redis:
extends:
file: docker-compose.base.yml
service: redis
restart: always
rabbitmq:
extends:
file: docker-compose.base.yml
service: rabbitmq
restart: always

43
lib/data/CapeItem.go Normal file
View File

@@ -0,0 +1,43 @@
package data
import (
"io"
"os"
"fmt"
"strings"
"crypto/md5"
"encoding/hex"
"elyby/minecraft-skinsystem/lib/services"
)
type CapeItem struct {
File *os.File
}
func FindCapeByUsername(username string) (CapeItem, error) {
var record CapeItem
file, err := os.Open(services.RootFolder + "/data/capes/" + strings.ToLower(username) + ".png")
if (err != nil) {
return record, CapeNotFound{username}
}
record.File = file
return record, err
}
func (cape *CapeItem) CalculateHash() string {
hasher := md5.New()
io.Copy(hasher, cape.File)
return hex.EncodeToString(hasher.Sum(nil))
}
type CapeNotFound struct {
Who string
}
func (e CapeNotFound) Error() string {
return fmt.Sprintf("Cape file not found. Required username \"%v\"", e.Who)
}

100
lib/data/SkinItem.go Normal file
View File

@@ -0,0 +1,100 @@
package data
import (
"log"
"fmt"
"encoding/json"
"elyby/minecraft-skinsystem/lib/services"
"elyby/minecraft-skinsystem/lib/tools"
"github.com/mediocregopher/radix.v2/redis"
)
type SkinItem struct {
UserId int `json:"userId"`
Username string `json:"username"`
SkinId int `json:"skinId"`
Url string `json:"url"`
Is1_8 bool `json:"is1_8"`
IsSlim bool `json:"isSlim"`
Hash string `json:"hash"`
oldUsername string
}
const accountIdToUsernameKey string = "hash:username-to-account-id"
func (s *SkinItem) Save() {
str, _ := json.Marshal(s)
pool, _ := services.RedisPool.Get()
pool.Cmd("MULTI")
// Если пользователь сменил ник, то мы должны удать его ключ
if (s.oldUsername != "" && s.oldUsername != s.Username) {
pool.Cmd("DEL", tools.BuildKey(s.oldUsername))
}
// Если это новая запись или если пользователь сменил ник, то обновляем значение в хэш-таблице
if (s.oldUsername != "" || s.oldUsername != s.Username) {
pool.Cmd("HSET", accountIdToUsernameKey, s.UserId, s.Username)
}
pool.Cmd("SET", tools.BuildKey(s.Username), str)
pool.Cmd("EXEC")
s.oldUsername = s.Username
}
func (s *SkinItem) Delete() {
if (s.oldUsername == "") {
return;
}
pool, _ := services.RedisPool.Get()
pool.Cmd("MULTI")
pool.Cmd("DEL", tools.BuildKey(s.oldUsername))
pool.Cmd("HDEL", accountIdToUsernameKey, s.UserId)
pool.Cmd("EXEC")
}
func FindSkinByUsername(username string) (SkinItem, error) {
var record SkinItem;
response := services.RedisPool.Cmd("GET", tools.BuildKey(username));
if (response.IsType(redis.Nil)) {
return record, SkinNotFound{username}
}
result, err := response.Str()
if (err == nil) {
decodeErr := json.Unmarshal([]byte(result), &record)
if (decodeErr != nil) {
log.Println("Cannot decode record data")
}
record.oldUsername = record.Username
}
return record, err
}
func FindSkinById(id int) (SkinItem, error) {
response := services.RedisPool.Cmd("HGET", accountIdToUsernameKey, id);
if (response.IsType(redis.Nil)) {
return SkinItem{}, SkinNotFound{"unknown"}
}
username, _ := response.Str()
return FindSkinByUsername(username)
}
type SkinNotFound struct {
Who string
}
func (e SkinNotFound) Error() string {
return fmt.Sprintf("Skin data not found. Required username \"%v\"", e.Who)
}

View File

@@ -0,0 +1,21 @@
package data
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"`
}

35
lib/routes/Cape.go Normal file
View File

@@ -0,0 +1,35 @@
package routes
import (
"io"
"log"
"net/http"
"github.com/gorilla/mux"
"elyby/minecraft-skinsystem/lib/tools"
"elyby/minecraft-skinsystem/lib/data"
)
func Cape(response http.ResponseWriter, request *http.Request) {
username := tools.ParseUsername(mux.Vars(request)["username"])
log.Println("request cape for username " + username)
rec, err := data.FindCapeByUsername(username)
if (err != nil) {
http.Redirect(response, request, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301)
}
request.Header.Set("Content-Type", "image/png")
io.Copy(response, rec.File)
}
func CapeGET(w http.ResponseWriter, r *http.Request) {
username := r.URL.Query().Get("name")
if username == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
mux.Vars(r)["username"] = username
Cape(w, r)
}

31
lib/routes/Face.go Normal file
View File

@@ -0,0 +1,31 @@
package routes
import (
"log"
"net/http"
"github.com/gorilla/mux"
"elyby/minecraft-skinsystem/lib/tools"
"elyby/minecraft-skinsystem/lib/data"
)
const defaultHash = "default"
func Face(w http.ResponseWriter, r *http.Request) {
username := tools.ParseUsername(mux.Vars(r)["username"])
log.Println("request skin for username " + username);
rec, err := data.FindSkinByUsername(username)
var hash string
if (err != nil || rec.SkinId == 0) {
hash = defaultHash;
} else {
hash = rec.Hash
}
http.Redirect(w, r, tools.BuildElyUrl(buildFaceUrl(hash)), 301);
}
func buildFaceUrl(hash string) string {
return "/minecfaft/skin_buffer/faces/" + hash + ".png"
}

View File

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

18
lib/routes/NotFound.go Normal file
View File

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

29
lib/routes/SetSkin.go Normal file
View File

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

34
lib/routes/Skin.go Normal file
View File

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

61
lib/routes/Textures.go Normal file
View File

@@ -0,0 +1,61 @@
package routes
import (
"log"
"net/http"
"encoding/json"
"github.com/gorilla/mux"
"elyby/minecraft-skinsystem/lib/data"
"elyby/minecraft-skinsystem/lib/tools"
"elyby/minecraft-skinsystem/lib/services"
)
func Textures(w http.ResponseWriter, r *http.Request) {
username := tools.ParseUsername(mux.Vars(r)["username"])
log.Println("request textures for username " + username)
rec, err := data.FindSkinByUsername(username)
if (err != nil || rec.SkinId == 0) {
rec.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png"
rec.Hash = string(tools.BuildNonElyTexturesHash(username))
} else {
rec.Url = tools.BuildElyUrl(rec.Url)
}
textures := data.TexturesResponse{
Skin: &data.Skin{
Url: rec.Url,
Hash: rec.Hash,
},
}
if (rec.IsSlim) {
textures.Skin.Metadata = &data.SkinMetadata{
Model: "slim",
}
}
capeRec, err := data.FindCapeByUsername(username)
if (err == nil) {
capeUrl, err := services.Router.Get("cloaks").URL("username", username)
if (err != nil) {
log.Println(err.Error())
}
var scheme string = "http://";
if (r.TLS != nil) {
scheme = "https://"
}
textures.Cape = &data.Cape{
Url: scheme + r.Host + capeUrl.String(),
Hash: capeRec.CalculateHash(),
}
}
response,_ := json.Marshal(textures)
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}

15
lib/services/services.go Normal file
View File

@@ -0,0 +1,15 @@
package services
import (
"github.com/mediocregopher/radix.v2/pool"
"github.com/streadway/amqp"
"github.com/gorilla/mux"
)
var Router *mux.Router
var RedisPool *pool.Pool
var RabbitMQChannel *amqp.Channel
var RootFolder string

40
lib/tools/tools.go Normal file
View File

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

22
lib/tools/tools_test.go Normal file
View File

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

52
lib/worker/handlers.go Normal file
View File

@@ -0,0 +1,52 @@
package worker
import (
"elyby/minecraft-skinsystem/lib/data"
"log"
)
func handleChangeUsername(model usernameChanged) (bool) {
if (model.OldUsername == "") {
record := data.SkinItem{
UserId: model.AccountId,
Username: model.NewUsername,
}
record.Save()
return true
}
record, err := data.FindSkinByUsername(model.OldUsername)
if (err != nil) {
log.Println("Exit by not found record")
// TODO: я не уверен, что это валидное поведение
// Суть в том, что здесь может возникнуть ошибка в том случае, если записи в базе нету
// а значит его нужно, как минимум, зарегистрировать
return true
}
record.Username = model.NewUsername
record.Save()
log.Println("all saved!")
return true
}
func handleSkinChanged(model skinChanged) (bool) {
record, err := data.FindSkinById(model.AccountId)
if (err != nil) {
return true
}
record.SkinId = model.SkinId
record.Hash = model.Hash
record.Is1_8 = model.Is1_8
record.IsSlim = model.IsSlim
record.Url = model.Url
record.Save()
return true
}

17
lib/worker/models.go Normal file
View File

@@ -0,0 +1,17 @@
package worker
type usernameChanged struct {
AccountId int `json:"accountId"`
OldUsername string `json:"oldUsername"`
NewUsername string `json:"newUsername"`
}
type skinChanged struct {
AccountId int `json:"userId"`
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"`
}

88
lib/worker/worker.go Normal file
View File

@@ -0,0 +1,88 @@
package worker
import (
"log"
"encoding/json"
"elyby/minecraft-skinsystem/lib/services"
)
const exchangeName string = "events"
const queueName string = "skinsystem-accounts-events"
func Listen() {
var err error
ch := services.RabbitMQChannel
err = ch.ExchangeDeclare(
exchangeName, // name
"topic", // type
true, // durable
false, // auto-deleted
false, // internal
false, // no-wait
nil, // arguments
)
failOnError(err, "Failed to declare an exchange")
_, err = ch.QueueDeclare(
queueName, // name
true, // durable
false, // delete when usused
false, // exclusive
false, // no-wait
nil, // arguments
)
failOnError(err, "Failed to declare a queue")
err = ch.QueueBind(queueName, "accounts.username-changed", exchangeName, false, nil)
failOnError(err, "Failed to bind a queue")
err = ch.QueueBind(queueName, "accounts.skin-changed", exchangeName, false, nil)
failOnError(err, "Failed to bind a queue")
msgs, err := ch.Consume(
queueName, // queue
"", // consumer
false, // auto-ack
false, // exclusive
false, // no-local
false, // no-wait
nil, // args
)
failOnError(err, "Failed to register a consumer")
forever := make(chan bool)
go func() {
for d := range msgs {
log.Println("Incoming message with routing key " + d.RoutingKey)
var result bool = true;
switch d.RoutingKey {
case "accounts.username-changed":
var model usernameChanged
json.Unmarshal(d.Body, &model)
result = handleChangeUsername(model)
case "accounts.skin-changed":
var model skinChanged
json.Unmarshal(d.Body, &model)
result = handleSkinChanged(model)
}
if (result) {
d.Ack(false)
} else {
d.Reject(true)
}
}
}()
<-forever
}
func failOnError(err error, msg string) {
if err != nil {
log.Fatalf("%s: %s", msg, err)
}
}

108
minecraft-skinsystem.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"os"
"log"
"runtime"
"time"
"net/http"
"path/filepath"
"github.com/gorilla/mux"
"github.com/streadway/amqp"
"github.com/mediocregopher/radix.v2/pool"
"elyby/minecraft-skinsystem/lib/routes"
"elyby/minecraft-skinsystem/lib/services"
"elyby/minecraft-skinsystem/lib/worker"
)
const redisPoolSize int = 10
func main() {
log.Println("Starting...")
runtime.GOMAXPROCS(runtime.NumCPU())
log.Println("Connecting to redis")
var redisString = os.Getenv("REDIS_ADDR")
if (redisString == "") {
redisString = "redis:6379"
}
redisPool, redisErr := pool.New("tcp", redisString, redisPoolSize)
if (redisErr != nil) {
log.Fatal("Redis unavailable")
}
log.Println("Connected to redis")
log.Println("Connecting to rabbitmq")
// TODO: rabbitmq становится доступен не сразу. Нужно дождаться, пока он станет доступен, периодически повторяя запросы
var rabbitmqString = os.Getenv("RABBITMQ_ADDR")
if (rabbitmqString == "") {
rabbitmqString = "amqp://ely-skinsystem-app:ely-skinsystem-app-password@rabbitmq:5672/%2fely"
}
rabbitConnection, rabbitmqErr := amqp.Dial(rabbitmqString)
if (rabbitmqErr != nil) {
log.Fatalf("%s", rabbitmqErr)
}
log.Println("Connected to rabbitmq. Trying to open a channel")
rabbitChannel, rabbitmqErr := rabbitConnection.Channel()
if (rabbitmqErr != nil) {
log.Fatalf("%s", rabbitmqErr)
}
log.Println("Connected to rabbitmq channel")
router := mux.NewRouter().StrictSlash(true)
router.HandleFunc("/skins/{username}", routes.Skin).Methods("GET").Name("skins")
router.HandleFunc("/cloaks/{username}", routes.Cape).Methods("GET").Name("cloaks")
router.HandleFunc("/textures/{username}", routes.Textures).Methods("GET").Name("textures")
router.HandleFunc("/skins/{username}/face", routes.Face).Methods("GET").Name("faces")
router.HandleFunc("/skins/{username}/face.png", routes.Face).Methods("GET").Name("faces")
// Legacy
router.HandleFunc("/minecraft.php", routes.MinecraftPHP).Methods("GET")
router.HandleFunc("/skins/", routes.SkinGET).Methods("GET")
router.HandleFunc("/cloaks/", routes.CapeGET).Methods("GET")
// 404
router.NotFoundHandler = http.HandlerFunc(routes.NotFound)
apiRouter := router.PathPrefix("/api").Subrouter()
apiRouter.HandleFunc("/user/{username}/skin", routes.SetSkin).Methods("POST")
services.Router = router
services.RedisPool = redisPool
services.RabbitMQChannel = rabbitChannel
_, file, _, _ := runtime.Caller(0)
services.RootFolder = filepath.Dir(file)
go func() {
period := 5
for {
time.Sleep(time.Duration(period) * time.Second)
resp := services.RedisPool.Cmd("PING")
if (resp.Err == nil) {
// Если редис успешно пинганулся, значит всё хорошо
continue
}
log.Println("Redis not pinged. Try to reconnect")
newPool, redisErr := pool.New("tcp", redisString, redisPoolSize)
if (redisErr != nil) {
log.Printf("Cannot reconnect to redis, waiting %d seconds\n", period)
} else {
services.RedisPool = newPool
log.Println("Reconnected")
}
}
}()
go worker.Listen()
log.Println("Started");
log.Fatal(http.ListenAndServe(":80", router))
}

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