From d11a1c58c5178770dfe7b1264e5cc7afb804fc86 Mon Sep 17 00:00:00 2001 From: Jacob Gunther Date: Sun, 27 Aug 2023 21:38:35 -0500 Subject: [PATCH] Add format query parameter --- config.example.yml | 8 +++ go.mod | 14 +++-- go.sum | 24 ++++---- src/cache.go | 41 ++++---------- src/config.go | 19 +++++-- src/main.go | 27 +++------ src/renderer.go | 2 +- src/routes.go | 137 +++++++++++++++++++++------------------------ src/util.go | 101 ++++++++++++++++++++++++--------- 9 files changed, 204 insertions(+), 169 deletions(-) diff --git a/config.example.yml b/config.example.yml index a435576..52c4d39 100644 --- a/config.example.yml +++ b/config.example.yml @@ -7,46 +7,54 @@ routes: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 head: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 full_body: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 front_body: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 back_body: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 left_body: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 right_body: default_overlay: true default_download: false default_scale: 4 + default_format: png min_scale: 1 max_scale: 64 raw_skin: default_download: false + default_format: png cache: skin_cache_duration: 12h # 12 hours render_cache_duration: 12h # 12 hours diff --git a/go.mod b/go.mod index f6a483d..c40038e 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module github.com/mineatar-io/api-server -go 1.18 +go 1.21 + +toolchain go1.21.0 require ( github.com/go-redsync/redsync/v4 v4.8.1 - github.com/gofiber/fiber/v2 v2.48.0 + github.com/gofiber/fiber/v2 v2.49.0 github.com/mineatar-io/skin-render v1.0.9 - github.com/redis/go-redis/v9 v9.0.5 + github.com/redis/go-redis/v9 v9.1.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,18 +17,18 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-redis/redis/v8 v8.11.5 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.3.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.48.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect - golang.org/x/sys v0.10.0 // indirect + golang.org/x/sys v0.11.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 88666db..36c44ea 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/bsm/ginkgo/v2 v2.5.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= -github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= +github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0= +github.com/bsm/ginkgo/v2 v2.9.5/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.20.0/go.mod h1:JifAceMQ4crZIWYUKrlGcmbN3bqHogVTADMD2ATsbwk= github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -24,8 +26,8 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq github.com/go-redsync/redsync/v4 v4.8.1 h1:rq2RvdTI0obznMdxKUWGdmmulo7lS9yCzb8fgDKOlbM= github.com/go-redsync/redsync/v4 v4.8.1/go.mod h1:LmUAsQuQxhzZAoGY7JS6+dNhNmZyonMZiiEDY9plotM= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/gofiber/fiber/v2 v2.48.0 h1:cRVMCb9aUJDsyHxGFLwz/sGzDggdailZZyptU9F9cU0= -github.com/gofiber/fiber/v2 v2.48.0/go.mod h1:xqJgfqrc23FJuqGOW6DVgi3HyZEm2Mn9pRqUb2kHSX8= +github.com/gofiber/fiber/v2 v2.49.0 h1:xBVG2c66GDcWfww56xHvMn52Q0XX7UrSvjj6MD8/5EE= +github.com/gofiber/fiber/v2 v2.49.0/go.mod h1:oxpt7wQaEYgdDmq7nMxCGhilYicBLFnZ+jQSJcQDlSE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= @@ -43,8 +45,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -66,8 +68,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mineatar-io/skin-render v1.0.9 h1:EIdHfj01Rs4P8JUk6kuvCxZG6i46c8LNGJV7EttwqQ8= github.com/mineatar-io/skin-render v1.0.9/go.mod h1:KkgHwrhTIqD73dkmeQwh5k2aHuWS8cDahVmd64gM128= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -83,8 +85,8 @@ github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps= -github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= -github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/redis/go-redis/v9 v9.1.0 h1:137FnGdk+EQdCbye1FW+qOEcY5S+SpY9T0NiuqvtfMY= +github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH1cY3lZl4c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -134,8 +136,8 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= -golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= diff --git a/src/cache.go b/src/cache.go index b7df904..fa1ab4d 100644 --- a/src/cache.go +++ b/src/cache.go @@ -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. diff --git a/src/config.go b/src/config.go index 5627554..96b464c 100644 --- a/src/config.go +++ b/src/config.go @@ -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. diff --git a/src/main.go b/src/main.go index 4f68d0e..4139cfc 100644 --- a/src/main.go +++ b/src/main.go @@ -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) } diff --git a/src/renderer.go b/src/renderer.go index 01d8295..7082570 100644 --- a/src/renderer.go +++ b/src/renderer.go @@ -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 diff --git a/src/routes.go b/src/routes.go index db643d0..4b3f2a5 100644 --- a/src/routes.go +++ b/src/routes.go @@ -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) } diff --git a/src/util.go b/src/util.go index da6b44b..419fe8f 100644 --- a/src/util.go +++ b/src/util.go @@ -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[:]) }