360 lines
8.2 KiB
Go
360 lines
8.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"fmt"
|
|
"image"
|
|
"image/draw"
|
|
"image/png"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/mineatar-io/skin-render"
|
|
)
|
|
|
|
var (
|
|
//go:embed favicon.ico
|
|
favicon []byte
|
|
|
|
RenderTypeFullBody = "fullbody"
|
|
RenderTypeFrontBody = "frontbody"
|
|
RenderTypeBackBody = "backbody"
|
|
RenderTypeLeftBody = "leftbody"
|
|
RenderTypeRightBody = "rightbody"
|
|
RenderTypeFace = "face"
|
|
RenderTypeHead = "head"
|
|
)
|
|
|
|
// QueryParams is used by most all API routes as options for how the image should be rendered, or how errors should be handled.
|
|
type QueryParams struct {
|
|
Scale int `query:"scale"`
|
|
Download bool `query:"download"`
|
|
Overlay bool `query:"overlay"`
|
|
}
|
|
|
|
// PointerOf returns the value of the first argument as a pointer.
|
|
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 {
|
|
mutex := r.NewMutex(fmt.Sprintf("render-lock:%s-%d-%t-%s", renderType, opts.Scale, opts.Overlay, uuid))
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
|
}
|
|
|
|
cache, err := GetCachedRenderResult(renderType, uuid, opts)
|
|
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if cache != nil {
|
|
if conf.Environment == "development" {
|
|
log.Printf("Retrieved render from cache (type=%s, uuid=%s, slim=%v, scale=%d)\n", renderType, uuid, isSlim, opts.Scale)
|
|
}
|
|
|
|
return cache, true, nil
|
|
}
|
|
|
|
var (
|
|
result *image.NRGBA
|
|
renderOpts skin.Options = skin.Options{
|
|
Overlay: opts.Overlay,
|
|
Slim: isSlim,
|
|
Scale: opts.Scale,
|
|
}
|
|
)
|
|
|
|
switch renderType {
|
|
case RenderTypeFullBody:
|
|
{
|
|
result = skin.RenderBody(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
case RenderTypeFrontBody:
|
|
{
|
|
result = skin.RenderFrontBody(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
case RenderTypeBackBody:
|
|
{
|
|
result = skin.RenderBackBody(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
case RenderTypeLeftBody:
|
|
{
|
|
result = skin.RenderLeftBody(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
case RenderTypeRightBody:
|
|
{
|
|
result = skin.RenderRightBody(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
case RenderTypeHead:
|
|
{
|
|
result = skin.RenderHead(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
case RenderTypeFace:
|
|
{
|
|
result = skin.RenderFace(rawSkin, renderOpts)
|
|
|
|
break
|
|
}
|
|
default:
|
|
panic(fmt.Errorf("unknown render type: %s", renderType))
|
|
}
|
|
|
|
data, err := EncodePNG(result)
|
|
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if err = SetCachedRenderResult(renderType, uuid, opts, data); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if conf.Environment == "development" {
|
|
log.Printf("Rendered image (type=%s, uuid=%s, slim=%v, scale=%d)\n", renderType, uuid, isSlim, opts.Scale)
|
|
}
|
|
|
|
return data, false, nil
|
|
}
|
|
|
|
// ExtractUUID returns the UUID from the route param, allowing values such as "<uuid>.png" to be returned as "<uuid>".
|
|
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 = strings.ToLower(strings.ReplaceAll(value, "-", ""))
|
|
|
|
if len(value) != 32 {
|
|
return "", false
|
|
}
|
|
|
|
return value, true
|
|
}
|
|
|
|
// FetchImage fetches the image by the URL and returns it as a parsed image.
|
|
func FetchImage(url string) (*image.NRGBA, error) {
|
|
resp, err := http.Get(url)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
img, format, err := image.Decode(resp.Body)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if format != "NRGBA" {
|
|
outputImg := image.NewNRGBA(img.Bounds())
|
|
|
|
draw.Draw(outputImg, img.Bounds(), img, image.Pt(0, 0), draw.Src)
|
|
|
|
return outputImg, nil
|
|
}
|
|
|
|
return img.(*image.NRGBA), nil
|
|
}
|
|
|
|
// GetPlayerSkin fetches the skin of the Minecraft player by the UUID.
|
|
func GetPlayerSkin(uuid string) (*image.NRGBA, bool, error) {
|
|
if conf.Cache.EnableLocks {
|
|
mutex := r.NewMutex(fmt.Sprintf("skin-lock:%s", uuid))
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
|
}
|
|
|
|
// Get skin from cache, and return if it exists
|
|
if conf.Cache.SkinCacheDuration != nil {
|
|
rawSkin, slim, err := GetCachedSkin(uuid)
|
|
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
if rawSkin != nil {
|
|
return rawSkin, slim, nil
|
|
}
|
|
}
|
|
|
|
var (
|
|
err error = nil
|
|
skinImage *image.NRGBA = nil
|
|
rawSkin []byte = nil
|
|
isSlim bool = skin.IsSlimFromUUID(uuid)
|
|
profile *MinecraftProfile = nil
|
|
rawTextures string = ""
|
|
texturesProperty *MinecraftDecodedTextures = nil
|
|
)
|
|
|
|
// Get the textures metadata from Mojang about the Minecraft player
|
|
{
|
|
if profile, err = GetMinecraftProfile(uuid); err != nil {
|
|
return skin.GetDefaultSkin(isSlim), true, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Decode the raw textures value returned from the player's properties
|
|
{
|
|
if texturesProperty, err = GetDecodedTexturesValue(rawTextures); err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
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
|
|
}
|
|
} else {
|
|
if err = r.Delete(fmt.Sprintf("slim:%s", uuid)); err != nil {
|
|
return nil, false, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if conf.Environment == "development" {
|
|
log.Printf("Fetched player skin from Mojang (uuid=%s, slim=%v)\n", uuid, isSlim)
|
|
}
|
|
|
|
return skinImage, isSlim, nil
|
|
}
|
|
|
|
// EncodePNG encodes the image into PNG format and returns the data as a byte array.
|
|
func EncodePNG(img image.Image) ([]byte, error) {
|
|
buf := &bytes.Buffer{}
|
|
|
|
if err := png.Encode(buf, img); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// ParseQueryParams parses the query parameters from the request and returns a QueryParams struct, using default values from the provided configuration.
|
|
func ParseQueryParams(ctx *fiber.Ctx, route RouteConfig) *QueryParams {
|
|
args := ctx.Context().QueryArgs()
|
|
|
|
response := &QueryParams{
|
|
Scale: route.DefaultScale,
|
|
Download: route.DefaultDownload,
|
|
Overlay: route.DefaultOverlay,
|
|
}
|
|
|
|
if args.Has("scale") {
|
|
if scale, err := args.GetUint("scale"); err == nil {
|
|
response.Scale = Clamp(scale, route.MinScale, route.MaxScale)
|
|
}
|
|
}
|
|
|
|
if args.Has("overlay") {
|
|
response.Overlay = args.GetBool("overlay")
|
|
}
|
|
|
|
if args.Has("download") {
|
|
response.Download = args.GetBool("download")
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
// GetInstanceID returns the INSTANCE_ID environment variable parsed as an unsigned 16-bit integer.
|
|
func GetInstanceID() (uint16, error) {
|
|
if instanceID := os.Getenv("INSTANCE_ID"); len(instanceID) > 0 {
|
|
value, err := strconv.ParseUint(instanceID, 10, 16)
|
|
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
return uint16(value), nil
|
|
}
|
|
|
|
return 0, nil
|
|
}
|