Add format query parameter

This commit is contained in:
Jacob Gunther
2023-08-27 21:38:35 -05:00
parent 4adcd85f4b
commit d11a1c58c5
9 changed files with 204 additions and 169 deletions

View File

@@ -1,9 +1,10 @@
package main
import (
"encoding/json"
"fmt"
"image"
"net/url"
"strconv"
)
type ResultCacheKey struct {
@@ -14,21 +15,15 @@ type ResultCacheKey struct {
}
// 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,
}
func GetResultCacheKey(uuid, renderType string, opts *QueryParams) string {
values := &url.Values{}
values.Set("uuid", uuid)
values.Set("type", renderType)
values.Set("scale", strconv.FormatInt(int64(opts.Scale), 10))
values.Set("overlay", strconv.FormatBool(opts.Overlay))
values.Set("format", opts.Format)
rawKeyData, err := json.Marshal(rawKey)
if err != nil {
return "", err
}
return fmt.Sprintf("result:%s", SHA256(rawKeyData)), nil
return SHA256(values.Encode())
}
// GetCachedRenderResult returns the render result from Redis cache, or nil if it does not exist or cache is disabled.
@@ -37,13 +32,7 @@ func GetCachedRenderResult(renderType, uuid string, opts *QueryParams) ([]byte,
return nil, nil
}
key, err := GetResultCacheKey(uuid, renderType, opts)
if err != nil {
return nil, err
}
return r.GetBytes(key)
return r.GetBytes(fmt.Sprintf("result:%s", GetResultCacheKey(uuid, renderType, opts)))
}
// SetCachedRenderResult puts the render result into cache, or does nothing is cache is disabled.
@@ -52,13 +41,7 @@ func SetCachedRenderResult(renderType, uuid string, opts *QueryParams, data []by
return nil
}
key, err := GetResultCacheKey(uuid, renderType, opts)
if err != nil {
return err
}
return r.Set(key, data, *config.Cache.RenderCacheDuration)
return r.Set(fmt.Sprintf("result:%s", GetResultCacheKey(uuid, renderType, opts)), data, *config.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.

View File

@@ -21,6 +21,7 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
Head: RouteConfig{
DefaultOverlay: true,
@@ -28,6 +29,7 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
FullBody: RouteConfig{
DefaultOverlay: true,
@@ -35,6 +37,7 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
FrontBody: RouteConfig{
DefaultOverlay: true,
@@ -42,6 +45,7 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
BackBody: RouteConfig{
DefaultOverlay: true,
@@ -49,6 +53,7 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
LeftBody: RouteConfig{
DefaultOverlay: true,
@@ -56,6 +61,7 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
RightBody: RouteConfig{
DefaultOverlay: true,
@@ -63,9 +69,11 @@ var (
DefaultScale: 4,
MinScale: 1,
MaxScale: 64,
DefaultFormat: "png",
},
RawSkin: RouteConfig{
DefaultDownload: false,
DefaultFormat: "png",
},
},
Cache: CacheConfig{
@@ -100,11 +108,12 @@ type Routes struct {
// RouteConfig is the configuration data used by a single API route.
type RouteConfig struct {
DefaultScale int `yaml:"default_scale"`
DefaultOverlay bool `yaml:"default_overlay"`
DefaultDownload bool `yaml:"default_download"`
MinScale int `yaml:"min_scale"`
MaxScale int `yaml:"max_scale"`
DefaultScale int `yaml:"default_scale"`
DefaultOverlay bool `yaml:"default_overlay"`
DefaultDownload bool `yaml:"default_download"`
DefaultFormat string `yaml:"default_format"`
MinScale int `yaml:"min_scale"`
MaxScale int `yaml:"max_scale"`
}
// CacheConfig is the configuration data used to set TTL values for Redis keys.

View File

@@ -1,21 +1,25 @@
package main
import (
"errors"
"fmt"
"log"
"net/http"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
var (
app *fiber.App = fiber.New(fiber.Config{
DisableStartupMessage: true,
ErrorHandler: func(ctx *fiber.Ctx, err error) error {
log.Println(ctx.Request().URI(), err)
var fiberError *fiber.Error
if errors.As(err, &fiberError) {
return ctx.SendStatus(fiberError.Code)
}
log.Printf("Error: %v - URI: %s\n", err, ctx.Request().URI())
return ctx.SendStatus(http.StatusInternalServerError)
},
@@ -38,21 +42,6 @@ func init() {
log.Println("Successfully connected to Redis")
app.Use(recover.New())
if config.Environment == "development" {
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "HEAD,OPTIONS,GET",
ExposeHeaders: "X-Cache-Hit,X-Cache-Time-Remaining",
}))
app.Use(logger.New(logger.Config{
Format: "${time} ${ip}:${port} -> ${status}: ${method} ${path} (${latency})\n",
TimeFormat: "2006/01/02 15:04:05",
}))
}
if instanceID, err = GetInstanceID(); err != nil {
panic(err)
}

View File

@@ -105,7 +105,7 @@ func Render(renderType, uuid string, rawSkin *image.NRGBA, isSlim bool, opts *Qu
// Encode the image into a PNG in byte-array format
{
data, err = EncodePNG(result)
data, err = EncodeImage(result, opts)
if err != nil {
return nil, false, err

View File

@@ -5,22 +5,35 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
)
var (
lastCount uint64 = 0
lastCountRetrievedAt *time.Time = nil
lastCountMutex *sync.Mutex = &sync.Mutex{}
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/favicon"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
)
func init() {
app.Use(recover.New())
app.Use(favicon.New(favicon.Config{
Data: faviconData,
}))
if config.Environment == "development" {
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowMethods: "HEAD,OPTIONS,GET",
ExposeHeaders: "X-Cache-Hit,X-Cache-Time-Remaining",
}))
app.Use(logger.New(logger.Config{
Format: "${time} ${ip}:${port} -> ${status}: ${method} ${path} (${latency})\n",
TimeFormat: "2006/01/02 15:04:05",
}))
}
app.Get("/ping", PingHandler)
app.Get("/favicon.ico", FaviconHandler)
app.Get("/count", CountHandler)
app.Get("/list", ListHandler)
app.Get("/skin/:uuid", SkinHandler)
app.Get("/face/:uuid", FaceHandler)
@@ -30,11 +43,6 @@ func init() {
app.Get("/body/back/:uuid", BackBodyHandler)
app.Get("/body/left/:uuid", LeftBodyHandler)
app.Get("/body/right/:uuid", RightBodyHandler)
app.Use(NotFoundHandler)
}
type CountResponse struct {
Count uint64 `json:"count"`
}
// PingHandler is the API handler used for the `/ping` route.
@@ -42,48 +50,6 @@ func PingHandler(ctx *fiber.Ctx) error {
return ctx.SendStatus(http.StatusOK)
}
// FaviconHandler serves the favicon.ico file to any users that visit the API using a browser.
func FaviconHandler(ctx *fiber.Ctx) error {
return ctx.Type("ico").Send(favicon)
}
// CountHandler is the API handler used for the `/count` route.
func CountHandler(ctx *fiber.Ctx) error {
lastCountMutex.Lock()
defer lastCountMutex.Unlock()
if lastCountRetrievedAt == nil || time.Since(*lastCountRetrievedAt) > time.Minute*15 {
lastCount = 0
var (
keys []string
cursor uint64 = 0
err error
)
for {
keys, cursor, err = r.Scan(cursor, "unique:*", 25)
if err != nil {
return err
}
lastCount += uint64(len(keys))
if cursor == 0 {
break
}
}
lastCountRetrievedAt = PointerOf(time.Now())
}
return ctx.JSON(CountResponse{
Count: lastCount,
})
}
// ListHandler is the API handler used for the `/list` route.
func ListHandler(ctx *fiber.Ctx) error {
result := make([]string, 0)
@@ -117,6 +83,10 @@ func ListHandler(ctx *fiber.Ctx) error {
func SkinHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.RawSkin)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ctx.Params("uuid"))
if !ok {
@@ -129,23 +99,27 @@ func SkinHandler(ctx *fiber.Ctx) error {
return err
}
data, err := EncodePNG(rawSkin)
data, err := EncodeImage(rawSkin, opts)
if err != nil {
return err
}
if opts.Download {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, uuid, opts.Format))
}
return ctx.Type("png").Send(data)
return ctx.Type(opts.Format).Send(data)
}
// FaceHandler is the API handler used for the `/face/:uuid` route.
func FaceHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.Face)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -170,13 +144,17 @@ func FaceHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
return ctx.Type(opts.Format).Send(result)
}
// HeadHandler is the API handler used for the `/head/:uuid` route.
func HeadHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.Head)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -201,13 +179,17 @@ func HeadHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
return ctx.Type(opts.Format).Send(result)
}
// FullBodyHandler is the API handler used for the `/body/full/:uuid` route.
func FullBodyHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.FullBody)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -232,13 +214,17 @@ func FullBodyHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
return ctx.Type(opts.Format).Send(result)
}
// FrontBodyHandler is the API handler used for the `/body/front/:uuid` route.
func FrontBodyHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.FrontBody)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -263,13 +249,17 @@ func FrontBodyHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
return ctx.Type(opts.Format).Send(result)
}
// BackBodyHandler is the API handler used for the `/body/back/:uuid` route.
func BackBodyHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.BackBody)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -294,13 +284,17 @@ func BackBodyHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
return ctx.Type(opts.Format).Send(result)
}
// LeftBodyHandler is the API handler used for the `/body/left/:uuid` route.
func LeftBodyHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.LeftBody)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -325,13 +319,17 @@ func LeftBodyHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
return ctx.Type(opts.Format).Send(result)
}
// RightBodyHandler is the API handler used for the `/body/right/:uuid` route.
func RightBodyHandler(ctx *fiber.Ctx) error {
opts := ParseQueryParams(ctx, config.Routes.RightBody)
if opts == nil {
return nil
}
uuid, ok := ParseUUID(ExtractUUID(ctx))
if !ok {
@@ -356,10 +354,5 @@ func RightBodyHandler(ctx *fiber.Ctx) error {
ctx.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.png"`, uuid))
}
return ctx.Type("png").Send(result)
}
// NotFoundHandler is the API handler used for any requests that do not match an existing route.
func NotFoundHandler(ctx *fiber.Ctx) error {
return ctx.SendStatus(http.StatusNotFound)
return ctx.Type(opts.Format).Send(result)
}

View File

@@ -8,6 +8,8 @@ import (
"fmt"
"image"
"image/draw"
"image/gif"
"image/jpeg"
"image/png"
"log"
"net/http"
@@ -21,14 +23,21 @@ import (
var (
//go:embed favicon.ico
favicon []byte
faviconData []byte
AllowedFormats []string = []string{
"png",
"jpg",
"jpeg",
"gif",
}
)
// 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"`
Scale int
Download bool
Overlay bool
Format string
}
// PointerOf returns the value of the first argument as a pointer.
@@ -36,6 +45,19 @@ func PointerOf[T any](v T) *T {
return &v
}
// Contains returns true if the array contains the value.
func Contains[T comparable](arr []T, value T) bool {
for _, v := range arr {
if v != value {
continue
}
return true
}
return false
}
// 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 {
@@ -200,31 +222,58 @@ func EncodePNG(img image.Image) ([]byte, error) {
return buf.Bytes(), nil
}
// EncodeImage encodes the image into the format specified by the query parameters.
func EncodeImage(img image.Image, opts *QueryParams) ([]byte, error) {
buf := &bytes.Buffer{}
switch opts.Format {
case "png":
{
if err := png.Encode(buf, img); err != nil {
return nil, err
}
break
}
case "jpg", "jpeg":
{
if err := jpeg.Encode(buf, img, nil); err != nil {
return nil, err
}
break
}
case "gif":
{
if err := gif.Encode(buf, img, nil); err != nil {
return nil, err
}
break
}
default:
return nil, fmt.Errorf("invalid format: %s", opts.Format)
}
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()
format := ctx.Query("format", route.DefaultFormat)
response := &QueryParams{
Scale: route.DefaultScale,
Download: route.DefaultDownload,
Overlay: route.DefaultOverlay,
if !Contains(AllowedFormats, format) {
ctx.Status(http.StatusBadRequest).SendString("Invalid 'format' query parameter")
return nil
}
if args.Has("scale") {
if scale, err := args.GetUint("scale"); err == nil {
response.Scale = Clamp(scale, route.MinScale, route.MaxScale)
}
return &QueryParams{
Scale: Clamp(ctx.QueryInt("scale", route.DefaultScale), route.MinScale, route.MaxScale),
Download: ctx.QueryBool("download", route.DefaultDownload),
Overlay: ctx.QueryBool("overlay", route.DefaultOverlay),
Format: ctx.Query("format", route.DefaultFormat),
}
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.
@@ -242,9 +291,9 @@ func GetInstanceID() (uint16, error) {
return 0, nil
}
// SHA256 computes the SHA-256 hash of the input byte-array.
func SHA256(value []byte) string {
hash := sha256.Sum256(value)
// SHA256 computes the SHA-256 hash of the input string.
func SHA256(value string) string {
hash := sha256.Sum256([]byte(value))
return hex.EncodeToString(hash[:])
}