diff --git a/src/cache.go b/src/cache.go new file mode 100644 index 0000000..56c5379 --- /dev/null +++ b/src/cache.go @@ -0,0 +1,92 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "image" + "log" +) + +type ResultCacheKey struct { + UUID string `json:"uuid"` + Type string `json:"type"` + Scale int `json:"scale"` + Overlay bool `json:"overlay"` +} + +// GetCacheKey returns the key used in the cache based on the rendering options, calculated as an SHA-256 hash. +func GetResultCacheKey(uuid, renderType string, opts *QueryParams) (string, error) { + rawKey := ResultCacheKey{ + UUID: uuid, + Type: renderType, + Scale: opts.Scale, + Overlay: opts.Overlay, + } + + rawKeyData, err := json.Marshal(rawKey) + + if err != nil { + return "", err + } + + hash := sha256.Sum256(rawKeyData) + + return fmt.Sprintf("result:%s", hex.EncodeToString(hash[:])), nil +} + +// GetCachedRenderResult returns the render result from Redis cache, or nil if it does not exist or cache is disabled. +func GetCachedRenderResult(renderType, uuid string, opts *QueryParams) ([]byte, error) { + if conf.Cache.RenderCacheDuration == nil { + return nil, nil + } + + key, err := GetResultCacheKey(uuid, renderType, opts) + + if err != nil { + return nil, err + } + + return r.GetBytes(key) +} + +// SetCachedRenderResult puts the render result into cache, or does nothing is cache is disabled. +func SetCachedRenderResult(renderType, uuid string, opts *QueryParams, data []byte) error { + if conf.Cache.RenderCacheDuration == nil { + return nil + } + + key, err := GetResultCacheKey(uuid, renderType, opts) + + if err != nil { + return err + } + + return r.Set(key, data, *conf.Cache.RenderCacheDuration) +} + +// GetCachedSkin returns the raw skin of a player by UUID from the cache, also returning if the player has a slim player model. +func GetCachedSkin(uuid string) (*image.NRGBA, bool, error) { + cache, ok, err := r.GetNRGBA(fmt.Sprintf("skin:%s", uuid)) + + if err != nil { + return nil, false, err + } + + if ok { + slim, err := r.Exists(fmt.Sprintf("slim:%s", uuid)) + + if err != nil { + return nil, false, err + } + + if conf.Environment == "development" { + log.Printf("Retrieved player skin from cache (uuid=%s, slim=%v)\n", uuid, slim) + } + + return cache, slim, nil + } + + return nil, false, nil +} diff --git a/src/mojang.go b/src/mojang.go index 3a98d39..cd60321 100644 --- a/src/mojang.go +++ b/src/mojang.go @@ -8,8 +8,8 @@ import ( "net/http" ) -// MinecraftProfileTextures is texture information about a Minecraft profile returned from the Mojang API. -type MinecraftProfileTextures struct { +// MinecraftProfile is metadata about a Minecraft player returned from the Mojang API. +type MinecraftProfile struct { UUID string `json:"id"` Username string `json:"name"` Legacy bool `json:"legacy"` @@ -20,7 +20,7 @@ type MinecraftProfileTextures struct { } `json:"properties"` } -// MinecraftDecodedTextures is the decoded object of the Base64-encoded values property in a MinecraftProfileTextures texture value. +// MinecraftDecodedTextures is the decoded object of the base64-encoded values property in a MinecraftProfile properties value. type MinecraftDecodedTextures struct { Timestamp int64 `json:"timestamp"` UUID string `json:"uuid"` @@ -39,8 +39,8 @@ type MinecraftDecodedTextures struct { } `json:"textures"` } -// GetProfileTextures returns the textures of a Minecraft player from Mojang. -func GetProfileTextures(uuid string) (*MinecraftProfileTextures, error) { +// GetMinecraftProfile returns the textures of a Minecraft player from Mojang. +func GetMinecraftProfile(uuid string) (*MinecraftProfile, error) { req, err := http.NewRequest("GET", fmt.Sprintf("https://sessionserver.mojang.com/session/minecraft/profile/%s", uuid), nil) if err != nil { @@ -71,7 +71,7 @@ func GetProfileTextures(uuid string) (*MinecraftProfileTextures, error) { return nil, err } - response := MinecraftProfileTextures{} + response := MinecraftProfile{} if err = json.Unmarshal(body, &response); err != nil { return nil, err diff --git a/src/util.go b/src/util.go index 416d05e..5f3bca2 100644 --- a/src/util.go +++ b/src/util.go @@ -42,6 +42,20 @@ func PointerOf[T any](v T) *T { return &v } +// Clamp clamps the input value between the minimum and maximum values. +// This method is preferred over `math.Min()` and `math.Max()` to prevent any type coercion between floats and integers. +func Clamp[T int | uint | int8 | uint8 | int16 | uint16 | int32 | uint32 | int64 | uint64](value, min, max T) T { + if value > max { + return max + } + + if value < min { + return min + } + + return value +} + // Render will render the image using the specified details and return the result. func Render(renderType, uuid string, rawSkin *image.NRGBA, isSlim bool, opts *QueryParams) ([]byte, bool, error) { if conf.Cache.EnableLocks { @@ -138,32 +152,14 @@ func Render(renderType, uuid string, rawSkin *image.NRGBA, isSlim bool, opts *Qu return data, false, nil } -// GetCachedRenderResult returns the render result from Redis cache, or nil if it does not exist or cache is disabled. -func GetCachedRenderResult(renderType, uuid string, opts *QueryParams) ([]byte, error) { - if conf.Cache.RenderCacheDuration == nil { - return nil, nil - } - - return r.GetBytes(fmt.Sprintf("result:%s-%d-%t-%s", renderType, opts.Scale, opts.Overlay, uuid)) -} - -// SetCachedRenderResult puts the render result into cache, or does nothing is cache is disabled. -func SetCachedRenderResult(renderType, uuid string, opts *QueryParams, data []byte) error { - if conf.Cache.RenderCacheDuration == nil { - return nil - } - - return r.Set(fmt.Sprintf("result:%s-%d-%t-%s", renderType, opts.Scale, opts.Overlay, uuid), data, *conf.Cache.RenderCacheDuration) -} - -// FormatUUID returns the UUID string without any dashes. -func FormatUUID(uuid string) string { - return strings.ToLower(strings.ReplaceAll(uuid, "-", "")) +// ExtractUUID returns the UUID from the route param, allowing values such as ".png" to be returned as "". +func ExtractUUID(ctx *fiber.Ctx) string { + return strings.Split(ctx.Params("uuid"), ".")[0] } // ParseUUID parses the UUID given by the route parameters, and returns a boolean if the UUID is valid. func ParseUUID(value string) (string, bool) { - value = FormatUUID(value) + value = strings.ToLower(strings.ReplaceAll(value, "-", "")) if len(value) != 32 { return "", false @@ -208,88 +204,90 @@ func GetPlayerSkin(uuid string) (*image.NRGBA, bool, error) { defer mutex.Unlock() } + // Get skin from cache, and return if it exists if conf.Cache.SkinCacheDuration != nil { - cache, ok, err := r.GetNRGBA(fmt.Sprintf("skin:%s", uuid)) + rawSkin, slim, err := GetCachedSkin(uuid) if err != nil { return nil, false, err } - if ok { - slim, err := r.Exists(fmt.Sprintf("slim:%s", uuid)) - - if err != nil { - return nil, false, err - } - - if conf.Environment == "development" { - log.Printf("Retrieved player skin from cache (uuid=%s, slim=%v)\n", uuid, slim) - } - - return cache, slim, nil + if rawSkin != nil { + return rawSkin, slim, nil } } - isSlimFromUUID := skin.IsSlimFromUUID(uuid) + var ( + err error = nil + skinImage *image.NRGBA = nil + rawSkin []byte = nil + isSlim bool = skin.IsSlimFromUUID(uuid) + profile *MinecraftProfile = nil + rawTextures string = "" + texturesProperty *MinecraftDecodedTextures = nil + ) - textures, err := GetProfileTextures(uuid) - - if err != nil { - return skin.GetDefaultSkin(isSlimFromUUID), true, nil - } - - if textures == nil { - return skin.GetDefaultSkin(isSlimFromUUID), isSlimFromUUID, nil - } - - if err = r.Set(fmt.Sprintf("unique:%s", textures.UUID), "0", 0); err != nil { - return nil, false, err - } - - value := "" - - for _, property := range textures.Properties { - if property.Name != "textures" { - continue + // Get the textures metadata from Mojang about the Minecraft player + { + if profile, err = GetMinecraftProfile(uuid); err != nil { + return skin.GetDefaultSkin(isSlim), true, nil } - value = property.Value + if profile == nil { + return skin.GetDefaultSkin(isSlim), isSlim, nil + } + + if err = r.Set(fmt.Sprintf("unique:%s", profile.UUID), "0", 0); err != nil { + return nil, false, err + } } - if len(value) < 1 { - return skin.GetDefaultSkin(isSlimFromUUID), isSlimFromUUID, nil + // Locate the skin information within the Minecraft profile properties + { + for _, property := range profile.Properties { + if property.Name != "textures" { + continue + } + + rawTextures = property.Value + } + + if len(rawTextures) < 1 { + return skin.GetDefaultSkin(isSlim), isSlim, nil + } } - texturesResult, err := GetDecodedTexturesValue(value) - - if err != nil { - return nil, false, err - } - - if len(texturesResult.Textures.Skin.URL) < 1 { - return skin.GetDefaultSkin(isSlimFromUUID), isSlimFromUUID, nil - } - - slim := texturesResult.Textures.Skin.Metadata.Model == "slim" - - skin, err := FetchImage(texturesResult.Textures.Skin.URL) - - if err != nil { - return nil, false, err - } - - encodedSkin, err := EncodePNG(skin) - - if err != nil { - return nil, false, err - } - - if conf.Cache.SkinCacheDuration != nil { - if err = r.Set(fmt.Sprintf("skin:%s", uuid), encodedSkin, *conf.Cache.SkinCacheDuration); err != nil { + // Decode the raw textures value returned from the player's properties + { + if texturesProperty, err = GetDecodedTexturesValue(rawTextures); err != nil { return nil, false, err } - if slim { + if len(texturesProperty.Textures.Skin.URL) < 1 { + return skin.GetDefaultSkin(isSlim), isSlim, nil + } + + isSlim = texturesProperty.Textures.Skin.Metadata.Model == "slim" + } + + // Fetch the raw skin image from the Mojang API + { + if skinImage, err = FetchImage(texturesProperty.Textures.Skin.URL); err != nil { + return nil, false, err + } + + if rawSkin, err = EncodePNG(skinImage); err != nil { + return nil, false, err + } + } + + // Put the skin into cache so it can be used for future requests + if conf.Cache.SkinCacheDuration != nil { + if err = r.Set(fmt.Sprintf("skin:%s", uuid), rawSkin, *conf.Cache.SkinCacheDuration); err != nil { + return nil, false, err + } + + if isSlim { if err = r.Set(fmt.Sprintf("slim:%s", uuid), "true", *conf.Cache.SkinCacheDuration); err != nil { return nil, false, err } @@ -301,24 +299,10 @@ func GetPlayerSkin(uuid string) (*image.NRGBA, bool, error) { } if conf.Environment == "development" { - log.Printf("Fetched player skin from Mojang (uuid=%s, slim=%v)\n", uuid, slim) + log.Printf("Fetched player skin from Mojang (uuid=%s, slim=%v)\n", uuid, isSlim) } - return skin, slim, nil -} - -// Clamp clamps the input value between the minimum and maximum values. -// This method is preferred over `math.Min()` and `math.Max()` to prevent any type coercion between floats and integers. -func Clamp[T int | uint | int8 | uint8 | int16 | uint16 | int32 | uint32 | int64 | uint64](value, min, max T) T { - if value > max { - return max - } - - if value < min { - return min - } - - return value + return skinImage, isSlim, nil } // EncodePNG encodes the image into PNG format and returns the data as a byte array. @@ -373,8 +357,3 @@ func GetInstanceID() (uint16, error) { return 0, nil } - -// ExtractUUID returns the user name from the route param, allowing values such as "PassTheMayo.png" to be returned as "PassTheMayo". -func ExtractUUID(ctx *fiber.Ctx) string { - return strings.Split(ctx.Params("uuid"), ".")[0] -}