Skip to content

Commit

Permalink
DEV 2240: Serve individual customizations from exporter (#30)
Browse files Browse the repository at this point in the history
* add /customizations/<type>/<name>.svg endpoint

* fix for S3 returning 403 instead of 404

* allow omitting hash from start of color value
  • Loading branch information
robbles committed Feb 3, 2023
1 parent 5d78cec commit 91f0ce0
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 19 deletions.
71 changes: 69 additions & 2 deletions http/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package http
import (
"errors"
"fmt"
"image/color"
"image/png"
"math"
"net/http"
Expand All @@ -17,6 +18,7 @@ import (

"github.com/BattlesnakeOfficial/exporter/engine"
"github.com/BattlesnakeOfficial/exporter/media"
"github.com/BattlesnakeOfficial/exporter/parse"
"github.com/BattlesnakeOfficial/exporter/render"
)

Expand All @@ -30,6 +32,12 @@ const maxGIFResolution = 504 * 504
// allowedPixelsPerSquare is a list of resolutions that the API will allow.
var allowedPixelsPerSquare = []int{10, 20, 30, 40}

var errBadRequest = fmt.Errorf("bad request")
var errBadColor = fmt.Errorf("color parameter should have the format #FFFFFF")

var reCustomizationParam = regexp.MustCompile(`^[A-Za-z-0-9#]{1,32}$`)
var reColorParam = regexp.MustCompile(`^#?[A-Fa-f0-9]{6}$`)

func handleVersion(w http.ResponseWriter, r *http.Request) {
version := os.Getenv("APP_VERSION")
if len(version) == 0 {
Expand All @@ -43,7 +51,6 @@ var reAvatarCustomizations = regexp.MustCompile(`(?P<key>[a-z-]{1,32}):(?P<value

func handleAvatar(w http.ResponseWriter, r *http.Request) {
subPath := strings.TrimPrefix(r.URL.Path, "/avatars")
errBadRequest := fmt.Errorf("bad request")
avatarSettings := render.AvatarSettings{}

// Extract width, height, and filetype
Expand Down Expand Up @@ -103,7 +110,7 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
return
}
case "color":
if len(cValue) != 7 || string(cValue[0]) != "#" {
if !reColorParam.MatchString(cValue) {
handleBadRequest(w, r, errBadRequest)
return
}
Expand Down Expand Up @@ -143,6 +150,66 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, avatarSVG)
}

func handleCustomization(w http.ResponseWriter, r *http.Request) {
customizationType := pat.Param(r, "type")
customizationName := pat.Param(r, "name")
ext := pat.Param(r, "ext")

if ext != "svg" {
handleBadRequest(w, r, errBadRequest)
return
}

if customizationType != "head" && customizationType != "tail" {
handleBadRequest(w, r, errBadRequest)
return
}

if !reCustomizationParam.MatchString(customizationName) {
handleBadRequest(w, r, errBadRequest)
return
}

var customizationColor color.Color = color.Black
colorParam := r.URL.Query().Get("color")
if colorParam != "" {
if !reColorParam.MatchString(colorParam) {
handleBadRequest(w, r, errBadColor)
return
}

customizationColor = parse.HexColor(colorParam)
}

flippedParam := r.URL.Query().Get("flipped") != ""

var svg string
var err error
var shouldFlip bool
switch customizationType {
case "head":
svg, err = media.GetHeadSVG(customizationName)
shouldFlip = flippedParam
case "tail":
svg, err = media.GetTailSVG(customizationName)
shouldFlip = !flippedParam
}

if err != nil {
if err == media.ErrNotFound {
handleError(w, r, err, http.StatusNotFound)
} else {
handleError(w, r, err, http.StatusInternalServerError)
}
return
}

svg = media.CustomizeSnakeSVG(svg, customizationColor, shouldFlip)

w.Header().Set("Content-Type", "image/svg+xml")
fmt.Fprint(w, svg)
}

func handleASCIIFrame(w http.ResponseWriter, r *http.Request) {
gameID := pat.Param(r, "game")
engineURL := r.URL.Query().Get("engine_url")
Expand Down
25 changes: 23 additions & 2 deletions http/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func TestHandlerAvatar_OK(t *testing.T) {
{"/head:beluga/tail:fish/color:%2331688e/500x100.svg", "image/svg+xml"},
{"/head:beluga/tail:fish/color:%23FfEeCc/500x100.svg", "image/svg+xml"},
{"/head:beluga/tail:fish/color:%23FfEeCc/500x100.png", "image/png"},
{"/head:beluga/tail:fish/color:FfEeCc/500x100.png", "image/png"},
} {
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/avatars%s", test.path), nil)
server.router.ServeHTTP(res, req)
Expand All @@ -64,10 +65,9 @@ func TestHandleAvatar_BadRequest(t *testing.T) {
"/500x100.zip", // Invalid extension
"/500x99999.svg", // Invalid extension

"/color:00FF00/500x100.svg", // Invalid color value
"/color:barf/500x100.svg", // Invalid color value
"/HEAD:default/500x100.svg", // Invalid characters
"/barf:true/500x100.svg", // Unrecognized param

}

for _, path := range badRequestPaths {
Expand All @@ -79,6 +79,27 @@ func TestHandleAvatar_BadRequest(t *testing.T) {
}
}

func TestHandlerCustomization_OK(t *testing.T) {
server := NewServer()

for _, test := range []struct {
path string
contentType string
}{
{"/head/beluga.svg", "image/svg+xml"},
{"/tail/fish.svg", "image/svg+xml"},
{"/tail/fish.svg?color=%2331688e", "image/svg+xml"},
{"/tail/fish.svg?color=31688e", "image/svg+xml"},
{"/tail/fish.svg?flipped=1", "image/svg+xml"},
{"/head/beluga.svg?color=%23ff00ff&flipped=1", "image/svg+xml"},
} {
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/customizations%s", test.path), nil)
server.router.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, test.path)
require.Equal(t, res.Result().Header.Get("content-type"), test.contentType)
}
}

func TestHandleGIFGame_NotFound(t *testing.T) {
server := NewServer()

Expand Down
2 changes: 2 additions & 0 deletions http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ func NewServer() *Server {
// Export routes
mux.HandleFunc(pat.Get("/avatars/*"), withCaching(handleAvatar))

mux.HandleFunc(pat.Get("/customizations/:type/:name.:ext"), withCaching(handleCustomization))

mux.HandleFunc(pat.Get("/games/:game/gif"), withCaching(handleGIFGame))
mux.HandleFunc(pat.Get("/games/:game/frames/:frame/ascii"), withCaching(handleASCIIFrame))
mux.HandleFunc(pat.Get("/games/:game/frames/:frame/gif"), withCaching(handleGIFFrame))
Expand Down
2 changes: 1 addition & 1 deletion media/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func getMediaResource(path string) (string, error) {
if err != nil {
return "", err
}
if response.StatusCode == http.StatusNotFound {
if response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusForbidden {
return "", ErrNotFound
}
if response.StatusCode != 200 {
Expand Down
14 changes: 9 additions & 5 deletions media/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func (sm svgManager) ensureDownloaded(mediaPath string, c color.Color) (string,
return "", err
}

svg = customiseSnakeSVG(svg, c)
svg = CustomizeSnakeSVG(svg, c, false)

err = sm.writeFile(customizedMediaPath, []byte(svg))
if err != nil {
Expand All @@ -203,8 +203,8 @@ func (sm svgManager) ensureDownloaded(mediaPath string, c color.Color) (string,
return customizedMediaPath, nil
}

// customiseSnakeSVG sets the fill colour for the outer SVG tag
func customiseSnakeSVG(svg string, c color.Color) string {
// CustomizeSnakeSVG sets the fill colour for the outer SVG tag and optionally flips it horizontally.
func CustomizeSnakeSVG(svg string, c color.Color, flipHorizontal bool) string {
var buf bytes.Buffer
decoder := xml.NewDecoder(strings.NewReader(svg))
encoder := xml.NewEncoder(&buf)
Expand All @@ -228,6 +228,10 @@ func customiseSnakeSVG(svg string, c color.Color) string {
if !rootSVGFound && v.Name.Local == "svg" {
rootSVGFound = true
attrs := append(v.Attr, xml.Attr{Name: xml.Name{Local: "fill"}, Value: colorToHex6(c)})
if flipHorizontal {
transform := "scale(-1, 1) translate(-100, 0)"
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "transform"}, Value: transform})
}
(&v).Attr = attrs
}

Expand All @@ -245,13 +249,13 @@ func customiseSnakeSVG(svg string, c color.Color) string {
}

if err := encoder.EncodeToken(token); err != nil {
log.Fatal(err)
log.WithError(err).Error("Failed to encode SVG")
}
}

// must call flush, otherwise some elements will be missing
if err := encoder.Flush(); err != nil {
log.Fatal(err)
log.WithError(err).Error("Failed to encode SVG")
}

return buf.String()
Expand Down
22 changes: 13 additions & 9 deletions media/media_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,24 +208,28 @@ func TestColorToHex6(t *testing.T) {
func TestCustomiseSVG(t *testing.T) {

// simple
customized := customiseSnakeSVG("<svg></svg>", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customized := CustomizeSnakeSVG("<svg></svg>", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
require.Equal(t, `<svg fill="#00ccaa"></svg>`, customized)

// make sure it doesn't panic with strange/bad inputs
customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customiseSnakeSVG("afe9*#@(#f2038208", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customiseSnakeSVG("<svg", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customiseSnakeSVG("<svg><foo></>", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customiseSnakeSVG("<</>>>//", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customiseSnakeSVG("<html></html>", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
CustomizeSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
CustomizeSnakeSVG("afe9*#@(#f2038208", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
CustomizeSnakeSVG("<svg", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
CustomizeSnakeSVG("<svg><foo></>", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
CustomizeSnakeSVG("<</>>>//", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
CustomizeSnakeSVG("<html></html>", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)

// nested
customized = customiseSnakeSVG("<svg><svg></svg></svg>", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customized = CustomizeSnakeSVG("<svg><svg></svg></svg>", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
require.Equal(t, `<svg fill="#00ccaa"><svg></svg></svg>`, customized, "nested SVG tags should be ignored")

// use a real head
customized = customiseSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff})
customized = CustomizeSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
require.Contains(t, customized, `<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill="#00ccaa">`)

// flip horizontal
customized = CustomizeSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff}, true)
require.Contains(t, customized, `<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill="#00ccaa" transform="scale(-1, 1) translate(-100, 0)">`)
}

const headSVG = `<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
Expand Down

0 comments on commit 91f0ce0

Please sign in to comment.