diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..dd69578 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +# Игнорим данные, т.к. они не нужны для внутреннего содержимого этого контейнера +data diff --git a/Dockerfile b/Dockerfile index c72d9d7..3ec24c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,9 @@ -FROM golang:1.7 +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 diff --git a/data/capes/.gitignore b/data/capes/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/data/capes/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/docker-compose.base.yml b/docker-compose.base.yml index 4632d92..d8df429 100644 --- a/docker-compose.base.yml +++ b/docker-compose.base.yml @@ -1,10 +1,13 @@ version: '2' services: - app: - ports: - - "80:80" - redis: - image: redis:3.0 + 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" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a1547cc..db6d8c0 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,17 +1,23 @@ version: '2' services: app: - extends: - file: docker-compose.base.yml - service: 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5663db1..3c86b31 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,12 +1,12 @@ version: '2' services: app: - extends: - file: docker-compose.base.yml - service: app - image: erickskrauch/ely-by-skinsystem:master + image: registry.ely.by/elyby/skinsystem:latest + ports: + - "80:80" links: - redis + - rabbitmq restart: always redis: @@ -14,3 +14,9 @@ services: file: docker-compose.base.yml service: redis restart: always + + rabbitmq: + extends: + file: docker-compose.base.yml + service: rabbitmq + restart: always diff --git a/lib/data/CapeItem.go b/lib/data/CapeItem.go new file mode 100644 index 0000000..8f03ad8 --- /dev/null +++ b/lib/data/CapeItem.go @@ -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) +} diff --git a/lib/data/DataNotFound.go b/lib/data/DataNotFound.go deleted file mode 100644 index 0f5e5c2..0000000 --- a/lib/data/DataNotFound.go +++ /dev/null @@ -1,11 +0,0 @@ -package data - -import "fmt" - -type DataNotFound struct { - Who string -} - -func (e DataNotFound) Error() string { - return fmt.Sprintf("Skin data not found. Required username \"%v\"", e.Who) -} diff --git a/lib/data/SkinItem.go b/lib/data/SkinItem.go index ee666e1..8ad55d7 100644 --- a/lib/data/SkinItem.go +++ b/lib/data/SkinItem.go @@ -2,6 +2,7 @@ package data import ( "log" + "fmt" "encoding/json" "elyby/minecraft-skinsystem/lib/services" @@ -11,25 +12,59 @@ import ( ) 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"` + 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) - services.RedisPool.Cmd("SET", tools.BuildKey(s.Username), str) + 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 FindRecord(username string) (SkinItem, error) { +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, DataNotFound{username} + return record, SkinNotFound{username} } result, err := response.Str() @@ -38,7 +73,28 @@ func FindRecord(username string) (SkinItem, error) { 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) +} diff --git a/lib/data/TexturesResponse.go b/lib/data/TexturesResponse.go index 4716a21..9d2d962 100644 --- a/lib/data/TexturesResponse.go +++ b/lib/data/TexturesResponse.go @@ -2,6 +2,7 @@ package data type TexturesResponse struct { Skin *Skin `json:"SKIN"` + Cape *Cape `json:"CAPE,omitempty"` } type Skin struct { @@ -13,3 +14,8 @@ type Skin struct { type SkinMetadata struct { Model string `json:"model"` } + +type Cape struct { + Url string `json:"url"` + Hash string `json:"hash"` +} diff --git a/lib/routes/Cape.go b/lib/routes/Cape.go index 20020b6..9d87832 100644 --- a/lib/routes/Cape.go +++ b/lib/routes/Cape.go @@ -1,18 +1,26 @@ package routes import ( + "io" "log" "net/http" "github.com/gorilla/mux" "elyby/minecraft-skinsystem/lib/tools" + "elyby/minecraft-skinsystem/lib/data" ) -func Cape(w http.ResponseWriter, r *http.Request) { - username := tools.ParseUsername(mux.Vars(r)["username"]) +func Cape(response http.ResponseWriter, request *http.Request) { + username := tools.ParseUsername(mux.Vars(request)["username"]) log.Println("request cape for username " + username) - http.Redirect(w, r, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301) + 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) { diff --git a/lib/routes/Face.go b/lib/routes/Face.go new file mode 100644 index 0000000..8e19018 --- /dev/null +++ b/lib/routes/Face.go @@ -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" +} diff --git a/lib/routes/Skin.go b/lib/routes/Skin.go index 650fd37..97a1e5c 100644 --- a/lib/routes/Skin.go +++ b/lib/routes/Skin.go @@ -13,7 +13,7 @@ import ( func Skin(w http.ResponseWriter, r *http.Request) { username := tools.ParseUsername(mux.Vars(r)["username"]) log.Println("request skin for username " + username); - rec, err := data.FindRecord(username) + rec, err := data.FindSkinByUsername(username) if (err != nil) { http.Redirect(w, r, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301) return diff --git a/lib/routes/Textures.go b/lib/routes/Textures.go index e298922..b1cd1ae 100644 --- a/lib/routes/Textures.go +++ b/lib/routes/Textures.go @@ -9,13 +9,14 @@ import ( "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.FindRecord(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)) @@ -36,6 +37,24 @@ func Textures(w http.ResponseWriter, r *http.Request) { } } + 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) diff --git a/lib/services/services.go b/lib/services/services.go index f0f612d..e4ce93d 100644 --- a/lib/services/services.go +++ b/lib/services/services.go @@ -2,9 +2,14 @@ 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 Router *mux.Router +var RabbitMQChannel *amqp.Channel + +var RootFolder string diff --git a/lib/worker/handlers.go b/lib/worker/handlers.go new file mode 100644 index 0000000..36186f8 --- /dev/null +++ b/lib/worker/handlers.go @@ -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 +} diff --git a/lib/worker/models.go b/lib/worker/models.go new file mode 100644 index 0000000..eede2c2 --- /dev/null +++ b/lib/worker/models.go @@ -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"` +} diff --git a/lib/worker/worker.go b/lib/worker/worker.go new file mode 100644 index 0000000..27a3407 --- /dev/null +++ b/lib/worker/worker.go @@ -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) + } +} diff --git a/minecraft-skinsystem.go b/minecraft-skinsystem.go index 49df237..9f90837 100644 --- a/minecraft-skinsystem.go +++ b/minecraft-skinsystem.go @@ -1,20 +1,23 @@ package main import ( + "os" "log" "runtime" - //"time" + "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" - //"github.com/mediocregopher/radix.v2/redis" + "elyby/minecraft-skinsystem/lib/worker" ) -const redisString string = "redis:6379" +const redisPoolSize int = 10 func main() { log.Println("Starting...") @@ -22,16 +25,43 @@ func main() { runtime.GOMAXPROCS(runtime.NumCPU()) log.Println("Connecting to redis") - redisPool, redisErr := pool.New("tcp", redisString, 10) - if redisErr != nil { + + 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") @@ -42,26 +72,36 @@ func main() { apiRouter := router.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/user/{username}/skin", routes.SetSkin).Methods("POST") - services.RedisPool = redisPool services.Router = router + services.RedisPool = redisPool + services.RabbitMQChannel = rabbitChannel - /*go func() { + _, file, _, _ := runtime.Caller(0) + services.RootFolder = filepath.Dir(file) + + go func() { + period := 5 for { - time.Sleep(5 * time.Second) + time.Sleep(time.Duration(period) * time.Second) - resp := services.Redis.Cmd("PING") - if (resp.Err != nil) { - log.Println("Redis not pinged. Try to reconnect") - newClient, redisErr := redis.Dial("tcp", redisString) - if (redisErr != nil) { - log.Println("Cannot reconnect to redis") - } else { - services.Redis = newClient - log.Println("Reconnected") - } + 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))