Skip to content

Commit

Permalink
DEV 1803: PNG endpoint for snake avatars (#29)
Browse files Browse the repository at this point in the history
* allow snake avatar endpoint to return PNG format

* allow passing empty values to avatar endpoint
  • Loading branch information
robbles authored Oct 12, 2022
1 parent b30844d commit 5d78cec
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 21 deletions.
25 changes: 22 additions & 3 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/png"
"math"
"net/http"
"os"
Expand Down Expand Up @@ -37,8 +38,8 @@ func handleVersion(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, version)
}

var reAvatarParams = regexp.MustCompile(`^/(?:[a-z-]{1,32}:[A-Za-z-0-9#]{1,32}/)*(?P<width>[0-9]{2,4})x(?P<height>[0-9]{2,4}).(?P<ext>[a-z]{3,4})$`)
var reAvatarCustomizations = regexp.MustCompile(`(?P<key>[a-z-]{1,32}):(?P<value>[A-Za-z-0-9#]{1,32})`)
var reAvatarParams = regexp.MustCompile(`^/(?:[a-z-]{1,32}:[A-Za-z-0-9#]{0,32}/)*(?P<width>[0-9]{2,4})x(?P<height>[0-9]{2,4}).(?P<ext>[a-z]{3,4})$`)
var reAvatarCustomizations = regexp.MustCompile(`(?P<key>[a-z-]{1,32}):(?P<value>[A-Za-z-0-9#]{0,32})`)

func handleAvatar(w http.ResponseWriter, r *http.Request) {
subPath := strings.TrimPrefix(r.URL.Path, "/avatars")
Expand Down Expand Up @@ -67,7 +68,7 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
avatarSettings.Height = pHeight

pExt := reParamsResult[3]
if pExt != "svg" {
if pExt != "svg" && pExt != "png" {
handleBadRequest(w, r, errBadRequest)
return
}
Expand All @@ -76,6 +77,10 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
reCustomizationResults := reAvatarCustomizations.FindAllStringSubmatch(subPath, -1)
for _, match := range reCustomizationResults {
cKey, cValue := match[1], match[2]
if cValue == "" {
// ignore empty values
continue
}
switch cKey {
case "head":
avatarSettings.HeadSVG, err = media.GetHeadSVG(cValue)
Expand Down Expand Up @@ -120,6 +125,20 @@ func handleAvatar(w http.ResponseWriter, r *http.Request) {
return
}

if pExt == "png" {
image, err := media.ConvertSVGStringToPNG(avatarSVG, avatarSettings.Width, avatarSettings.Height)
if err != nil {
handleError(w, r, err, http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "image/png")
if err := png.Encode(w, image); err != nil {
log.WithError(err).Error("unable to write PNG to response stream")
}
return
}

w.Header().Set("Content-Type", "image/svg+xml")
fmt.Fprint(w, avatarSVG)
}
Expand Down
31 changes: 19 additions & 12 deletions http/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,21 @@ func TestHandleVersion(t *testing.T) {
func TestHandlerAvatar_OK(t *testing.T) {
server := NewServer()

for _, path := range []string{
"/200x100.svg",
"/head:beluga/500x100.svg",
"/head:beluga/tail:fish/color:%2331688e/500x100.svg",
"/head:beluga/tail:fish/color:%23FfEeCc/500x100.svg",
for _, test := range []struct {
path string
contentType string
}{
{"/200x100.svg", "image/svg+xml"},
{"/head:beluga/500x100.svg", "image/svg+xml"},
{"/head:/tail:/color:/500x100.svg", "image/svg+xml"},
{"/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"},
} {
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/avatars%s", path), nil)
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/avatars%s", test.path), nil)
server.router.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, path)
require.Equal(t, http.StatusOK, res.Code, test.path)
require.Equal(t, res.Result().Header.Get("content-type"), test.contentType)
}
}

Expand All @@ -55,20 +61,21 @@ func TestHandleAvatar_BadRequest(t *testing.T) {
"/100x1.svg", // Invalid dimension
"/abcx100.svg", // Missing dimension
"/100xqwer.svg", // Missing dimension
"/500x100.png", // Invalid extension
"/500x100.zip", // Invalid extension
"/500x99999.svg", // Invalid extension

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

}

for _, path := range badRequestPaths {
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/avatars%s", path), nil)
server.router.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
t.Run(path, func(t *testing.T) {
req, res := fixtures.TestRequest(t, "GET", fmt.Sprintf("http://localhost/avatars%s", path), nil)
server.router.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
})
}
}

Expand Down
33 changes: 33 additions & 0 deletions inkscape/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,39 @@ func (c Client) SVGToPNG(path string, width, height int) (image.Image, error) {
return img, err
}

// SVGStringToPNG rasterizes the provided SVG text to PNG format.
func (c Client) SVGStringToPNG(svgText string, width, height int) (image.Image, error) {
if height < 1 {
return nil, errors.New("invalid height")
}
if width < 1 {
return nil, errors.New("invalid width")
}

cmd := exec.Command(c.cmd(), "--pipe", "-w", fmt.Sprint(width), "-h", fmt.Sprint(height), "--export-type=png", "--export-filename=-")
stdoutData := bytes.NewBuffer(nil)
stderrData := bytes.NewBuffer(nil)
cmd.Stdout = stdoutData
cmd.Stderr = stderrData
cmd.Stdin = bytes.NewBufferString(svgText)
err := cmd.Start()
if err != nil {
return nil, describeError(err, stderrData.String())
}
err = cmd.Wait()
if err != nil {
return nil, describeError(err, stderrData.String())
}

// if we get no bytes on stdout, that means something went wrong
if stdoutData.Len() == 0 {
return nil, errors.New("error processing SVG")
}

img, err := png.Decode(stdoutData)
return img, err
}

func (c Client) cmd() string {
if c.Command == "" {
return defaultCommand
Expand Down
28 changes: 22 additions & 6 deletions media/media.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ const (
// imageCache is a cache that contains image.Image values
var imageCache = cache.New(time.Hour, 10*time.Minute)

var inkscapeClient = &inkscape.Client{}

var baseDir = "media/assets"
var svgMgr = &svgManager{
baseDir: filepath.Join(baseDir, "downloads"),
inkscape: &inkscape.Client{},
inkscape: inkscapeClient,
}

// GetWatermarkPNG gets the watermark asset, scaled to the requested width/height
Expand Down Expand Up @@ -105,6 +107,25 @@ func getSnakeSVGImage(path, fallbackPath string, w, h int, c color.Color) (image
return img, err
}

func ConvertSVGStringToPNG(svg string, w, h int) (image.Image, error) {
// make sure inkscape is available, otherwise we can't create an image from an SVG
if !inkscapeClient.IsAvailable() {
return nil, errors.New("inkscape is not available - unable to convert SVG")
}

img, err := inkscapeClient.SVGStringToPNG(svg, w, h)
if err != nil {
log.WithError(err).Info("unable to rasterize SVG")
return nil, err
}
return img, nil
}

type svgManager struct {
baseDir string
inkscape *inkscape.Client
}

func (sm svgManager) loadSnakeSVGImage(mediaPath string, w, h int, c color.Color) (image.Image, error) {
key := imageCacheKey(mediaPath, w, h, c)
cachedImage, ok := imageCache.Get(key)
Expand Down Expand Up @@ -135,11 +156,6 @@ func (sm svgManager) loadSnakeSVGImage(mediaPath string, w, h int, c color.Color
return img, nil
}

type svgManager struct {
baseDir string
inkscape *inkscape.Client
}

func (sm svgManager) ensureSubdirExists(subDir string) error {
path := sm.getFullPath(subDir)
_, err := os.Stat(path)
Expand Down

0 comments on commit 5d78cec

Please sign in to comment.