chrly/mojangtextures/mojang_textures.go

180 lines
4.0 KiB
Go

package mojangtextures
import (
"errors"
"regexp"
"strings"
"sync"
"github.com/elyby/chrly/api/mojang"
)
type broadcastResult struct {
textures *mojang.SignedTexturesResponse
error error
}
type broadcaster struct {
lock sync.Mutex
listeners map[string][]chan *broadcastResult
}
func createBroadcaster() *broadcaster {
return &broadcaster{
listeners: make(map[string][]chan *broadcastResult),
}
}
// Returns a boolean value, which will be true if the passed username didn't exist before
func (c *broadcaster) AddListener(username string, resultChan chan *broadcastResult) bool {
c.lock.Lock()
defer c.lock.Unlock()
val, alreadyHasSource := c.listeners[username]
if alreadyHasSource {
c.listeners[username] = append(val, resultChan)
return false
}
c.listeners[username] = []chan *broadcastResult{resultChan}
return true
}
func (c *broadcaster) BroadcastAndRemove(username string, result *broadcastResult) {
c.lock.Lock()
defer c.lock.Unlock()
val, ok := c.listeners[username]
if !ok {
return
}
for _, channel := range val {
go func(channel chan *broadcastResult) {
channel <- result
close(channel)
}(channel)
}
delete(c.listeners, username)
}
// https://help.mojang.com/customer/portal/articles/928638
var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`)
type UUIDsProvider interface {
GetUuid(username string) (*mojang.ProfileInfo, error)
}
type TexturesProvider interface {
GetTextures(uuid string) (*mojang.SignedTexturesResponse, error)
}
type Emitter interface {
Emit(name string, args ...interface{})
}
type Provider struct {
Emitter
UUIDsProvider
TexturesProvider
Storage
onFirstCall sync.Once
*broadcaster
}
// TODO: move cache events on the corresponding level
func (ctx *Provider) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) {
ctx.onFirstCall.Do(func() {
ctx.broadcaster = createBroadcaster()
})
if !allowedUsernamesRegex.MatchString(username) {
return nil, errors.New("invalid username")
}
username = strings.ToLower(username)
ctx.Emit("mojang_textures:call")
uuid, err := ctx.Storage.GetUuid(username)
if err == nil && uuid == "" {
ctx.Emit("mojang_textures:usernames:cache_hit_nil")
return nil, nil
}
if uuid != "" {
ctx.Emit("mojang_textures:usernames:cache_hit")
textures, err := ctx.Storage.GetTextures(uuid)
if err == nil {
ctx.Emit("mojang_textures:textures:cache_hit")
return textures, nil
}
}
resultChan := make(chan *broadcastResult)
isFirstListener := ctx.broadcaster.AddListener(username, resultChan)
if isFirstListener {
go ctx.getResultAndBroadcast(username, uuid)
} else {
ctx.Emit("mojang_textures:already_processing")
}
result := <-resultChan
return result.textures, result.error
}
func (ctx *Provider) getResultAndBroadcast(username string, uuid string) {
ctx.Emit("mojang_textures:before_get_result")
result := ctx.getResult(username, uuid)
ctx.broadcaster.BroadcastAndRemove(username, result)
ctx.Emit("mojang_textures:after_get_result")
}
func (ctx *Provider) getResult(username string, uuid string) *broadcastResult {
if uuid == "" {
profile, err := ctx.UUIDsProvider.GetUuid(username)
if err != nil {
ctx.Emit("mojang_textures:usernames:error", err)
return &broadcastResult{nil, err}
}
uuid = ""
if profile != nil {
uuid = profile.Id
}
_ = ctx.Storage.StoreUuid(username, uuid)
if uuid == "" {
ctx.Emit("mojang_textures:usernames:uuid_miss")
return &broadcastResult{nil, nil}
}
ctx.Emit("mojang_textures:usernames:uuid_hit")
}
textures, err := ctx.TexturesProvider.GetTextures(uuid)
if err != nil {
ctx.Emit("mojang_textures:textures:error", err)
return &broadcastResult{nil, err}
}
// Mojang can respond with an error, but it will still count as a hit,
// therefore store the result even if textures is nil to prevent 429 error
ctx.Storage.StoreTextures(uuid, textures)
if textures != nil {
ctx.Emit("mojang_textures:textures:hit")
} else {
ctx.Emit("mojang_textures:textures:miss")
}
return &broadcastResult{textures, nil}
}