Skip to content

Commit 91f0ce0

Browse files
authored
DEV 2240: Serve individual customizations from exporter (#30)
* add /customizations/<type>/<name>.svg endpoint * fix for S3 returning 403 instead of 404 * allow omitting hash from start of color value
1 parent 5d78cec commit 91f0ce0

File tree

6 files changed

+117
-19
lines changed

6 files changed

+117
-19
lines changed

http/handlers.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package http
33
import (
44
"errors"
55
"fmt"
6+
"image/color"
67
"image/png"
78
"math"
89
"net/http"
@@ -17,6 +18,7 @@ import (
1718

1819
"github.com/BattlesnakeOfficial/exporter/engine"
1920
"github.com/BattlesnakeOfficial/exporter/media"
21+
"github.com/BattlesnakeOfficial/exporter/parse"
2022
"github.com/BattlesnakeOfficial/exporter/render"
2123
)
2224

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

35+
var errBadRequest = fmt.Errorf("bad request")
36+
var errBadColor = fmt.Errorf("color parameter should have the format #FFFFFF")
37+
38+
var reCustomizationParam = regexp.MustCompile(`^[A-Za-z-0-9#]{1,32}$`)
39+
var reColorParam = regexp.MustCompile(`^#?[A-Fa-f0-9]{6}$`)
40+
3341
func handleVersion(w http.ResponseWriter, r *http.Request) {
3442
version := os.Getenv("APP_VERSION")
3543
if len(version) == 0 {
@@ -43,7 +51,6 @@ var reAvatarCustomizations = regexp.MustCompile(`(?P<key>[a-z-]{1,32}):(?P<value
4351

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

4956
// Extract width, height, and filetype
@@ -103,7 +110,7 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
103110
return
104111
}
105112
case "color":
106-
if len(cValue) != 7 || string(cValue[0]) != "#" {
113+
if !reColorParam.MatchString(cValue) {
107114
handleBadRequest(w, r, errBadRequest)
108115
return
109116
}
@@ -143,6 +150,66 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
143150
fmt.Fprint(w, avatarSVG)
144151
}
145152

153+
func handleCustomization(w http.ResponseWriter, r *http.Request) {
154+
customizationType := pat.Param(r, "type")
155+
customizationName := pat.Param(r, "name")
156+
ext := pat.Param(r, "ext")
157+
158+
if ext != "svg" {
159+
handleBadRequest(w, r, errBadRequest)
160+
return
161+
}
162+
163+
if customizationType != "head" && customizationType != "tail" {
164+
handleBadRequest(w, r, errBadRequest)
165+
return
166+
}
167+
168+
if !reCustomizationParam.MatchString(customizationName) {
169+
handleBadRequest(w, r, errBadRequest)
170+
return
171+
}
172+
173+
var customizationColor color.Color = color.Black
174+
colorParam := r.URL.Query().Get("color")
175+
if colorParam != "" {
176+
if !reColorParam.MatchString(colorParam) {
177+
handleBadRequest(w, r, errBadColor)
178+
return
179+
}
180+
181+
customizationColor = parse.HexColor(colorParam)
182+
}
183+
184+
flippedParam := r.URL.Query().Get("flipped") != ""
185+
186+
var svg string
187+
var err error
188+
var shouldFlip bool
189+
switch customizationType {
190+
case "head":
191+
svg, err = media.GetHeadSVG(customizationName)
192+
shouldFlip = flippedParam
193+
case "tail":
194+
svg, err = media.GetTailSVG(customizationName)
195+
shouldFlip = !flippedParam
196+
}
197+
198+
if err != nil {
199+
if err == media.ErrNotFound {
200+
handleError(w, r, err, http.StatusNotFound)
201+
} else {
202+
handleError(w, r, err, http.StatusInternalServerError)
203+
}
204+
return
205+
}
206+
207+
svg = media.CustomizeSnakeSVG(svg, customizationColor, shouldFlip)
208+
209+
w.Header().Set("Content-Type", "image/svg+xml")
210+
fmt.Fprint(w, svg)
211+
}
212+
146213
func handleASCIIFrame(w http.ResponseWriter, r *http.Request) {
147214
gameID := pat.Param(r, "game")
148215
engineURL := r.URL.Query().Get("engine_url")

http/handlers_test.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func TestHandlerAvatar_OK(t *testing.T) {
3939
{"/head:beluga/tail:fish/color:%2331688e/500x100.svg", "image/svg+xml"},
4040
{"/head:beluga/tail:fish/color:%23FfEeCc/500x100.svg", "image/svg+xml"},
4141
{"/head:beluga/tail:fish/color:%23FfEeCc/500x100.png", "image/png"},
42+
{"/head:beluga/tail:fish/color:FfEeCc/500x100.png", "image/png"},
4243
} {
4344
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/avatars%s", test.path), nil)
4445
server.router.ServeHTTP(res, req)
@@ -64,10 +65,9 @@ func TestHandleAvatar_BadRequest(t *testing.T) {
6465
"/500x100.zip", // Invalid extension
6566
"/500x99999.svg", // Invalid extension
6667

67-
"/color:00FF00/500x100.svg", // Invalid color value
68+
"/color:barf/500x100.svg", // Invalid color value
6869
"/HEAD:default/500x100.svg", // Invalid characters
6970
"/barf:true/500x100.svg", // Unrecognized param
70-
7171
}
7272

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

82+
func TestHandlerCustomization_OK(t *testing.T) {
83+
server := NewServer()
84+
85+
for _, test := range []struct {
86+
path string
87+
contentType string
88+
}{
89+
{"/head/beluga.svg", "image/svg+xml"},
90+
{"/tail/fish.svg", "image/svg+xml"},
91+
{"/tail/fish.svg?color=%2331688e", "image/svg+xml"},
92+
{"/tail/fish.svg?color=31688e", "image/svg+xml"},
93+
{"/tail/fish.svg?flipped=1", "image/svg+xml"},
94+
{"/head/beluga.svg?color=%23ff00ff&flipped=1", "image/svg+xml"},
95+
} {
96+
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/customizations%s", test.path), nil)
97+
server.router.ServeHTTP(res, req)
98+
require.Equal(t, http.StatusOK, res.Code, test.path)
99+
require.Equal(t, res.Result().Header.Get("content-type"), test.contentType)
100+
}
101+
}
102+
82103
func TestHandleGIFGame_NotFound(t *testing.T) {
83104
server := NewServer()
84105

http/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ func NewServer() *Server {
3434
// Export routes
3535
mux.HandleFunc(pat.Get("/avatars/*"), withCaching(handleAvatar))
3636

37+
mux.HandleFunc(pat.Get("/customizations/:type/:name.:ext"), withCaching(handleCustomization))
38+
3739
mux.HandleFunc(pat.Get("/games/:game/gif"), withCaching(handleGIFGame))
3840
mux.HandleFunc(pat.Get("/games/:game/frames/:frame/ascii"), withCaching(handleASCIIFrame))
3941
mux.HandleFunc(pat.Get("/games/:game/frames/:frame/gif"), withCaching(handleGIFFrame))

media/api.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func getMediaResource(path string) (string, error) {
4545
if err != nil {
4646
return "", err
4747
}
48-
if response.StatusCode == http.StatusNotFound {
48+
if response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusForbidden {
4949
return "", ErrNotFound
5050
}
5151
if response.StatusCode != 200 {

media/media.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ func (sm svgManager) ensureDownloaded(mediaPath string, c color.Color) (string,
191191
return "", err
192192
}
193193

194-
svg = customiseSnakeSVG(svg, c)
194+
svg = CustomizeSnakeSVG(svg, c, false)
195195

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

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

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

247251
if err := encoder.EncodeToken(token); err != nil {
248-
log.Fatal(err)
252+
log.WithError(err).Error("Failed to encode SVG")
249253
}
250254
}
251255

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

257261
return buf.String()

media/media_internal_test.go

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,24 +208,28 @@ func TestColorToHex6(t *testing.T) {
208208
func TestCustomiseSVG(t *testing.T) {
209209

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

214214
// make sure it doesn't panic with strange/bad inputs
215-
customiseSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
216-
customiseSnakeSVG("afe9*#@(#f2038208", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
217-
customiseSnakeSVG("<svg", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
218-
customiseSnakeSVG("<svg><foo></>", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
219-
customiseSnakeSVG("<</>>>//", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
220-
customiseSnakeSVG("<html></html>", color.RGBA{0x00, 0xcc, 0xaa, 0xff})
215+
CustomizeSnakeSVG("", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
216+
CustomizeSnakeSVG("afe9*#@(#f2038208", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
217+
CustomizeSnakeSVG("<svg", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
218+
CustomizeSnakeSVG("<svg><foo></>", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
219+
CustomizeSnakeSVG("<</>>>//", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
220+
CustomizeSnakeSVG("<html></html>", color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
221221

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

226226
// use a real head
227-
customized = customiseSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff})
227+
customized = CustomizeSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff}, false)
228228
require.Contains(t, customized, `<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" fill="#00ccaa">`)
229+
230+
// flip horizontal
231+
customized = CustomizeSnakeSVG(headSVG, color.RGBA{0x00, 0xcc, 0xaa, 0xff}, true)
232+
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)">`)
229233
}
230234

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

0 commit comments

Comments
 (0)