diff --git a/api.go b/api.go
index fb47adc..084812a 100644
--- a/api.go
+++ b/api.go
@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"net/url"
- "path"
"sort"
"strconv"
"strings"
@@ -14,8 +13,9 @@ import (
"github.com/gorilla/feeds"
"github.com/gorilla/mux"
"github.com/pkg/errors"
- log "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus"
+ "github.com/Luzifer/go-latestver/internal/badge"
"github.com/Luzifer/go-latestver/internal/config"
"github.com/Luzifer/go-latestver/internal/database"
"github.com/Luzifer/go-latestver/internal/fetcher"
@@ -58,7 +58,7 @@ func catalogEntryToAPICatalogEntry(ce database.CatalogEntry) (apiCatalogEntry, e
return apiCatalogEntry{CatalogEntry: ce, CatalogMeta: *cm}, nil
}
-func handleBadgeRedirect(w http.ResponseWriter, r *http.Request) {
+func handleBadge(w http.ResponseWriter, r *http.Request) {
var (
compare = r.FormValue("compare")
vars = mux.Vars(r)
@@ -82,14 +82,11 @@ func handleBadgeRedirect(w http.ResponseWriter, r *http.Request) {
color = "red"
}
- target, err := url.Parse(cfg.BadgeGenInstance)
- if err != nil {
- http.Error(w, "Misconfigured BadgeGenInstance", http.StatusInternalServerError)
- return
+ svg := badge.Create(ce.Key(), cm.CurrentVersion, color)
+ w.Header().Add("Content-Type", "image/svg+xml")
+ if _, err = w.Write(svg); err != nil {
+ logrus.WithError(err).Error("writing SVG response")
}
-
- target.Path = path.Join(target.Path, "static", ce.Key(), cm.CurrentVersion, color)
- http.Redirect(w, r, target.String(), http.StatusFound)
}
func handleCatalogGet(w http.ResponseWriter, r *http.Request) {
@@ -106,14 +103,14 @@ func handleCatalogGet(w http.ResponseWriter, r *http.Request) {
ae, err := catalogEntryToAPICatalogEntry(ce)
if err != nil {
- log.WithError(err).Error("Unable to fetch catalog data")
+ logrus.WithError(err).Error("Unable to fetch catalog data")
http.Error(w, "Unable to fetch catalog data", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(ae); err != nil {
- log.WithError(err).Error("Unable to encode catalog entry")
+ logrus.WithError(err).Error("Unable to encode catalog entry")
http.Error(w, "Unable to encode catalog meta", http.StatusInternalServerError)
return
}
@@ -133,7 +130,7 @@ func handleCatalogGetVersion(w http.ResponseWriter, r *http.Request) {
cm, err := storage.Catalog.GetMeta(&ce)
if err != nil {
- log.WithError(err).Error("Unable to fetch catalog meta")
+ logrus.WithError(err).Error("Unable to fetch catalog meta")
http.Error(w, "Unable to fetch catalog meta", http.StatusInternalServerError)
return
}
@@ -150,7 +147,7 @@ func handleCatalogList(w http.ResponseWriter, _ *http.Request) {
ae, err := catalogEntryToAPICatalogEntry(ce)
if err != nil {
- log.WithError(err).Error("Unable to fetch catalog data")
+ logrus.WithError(err).Error("Unable to fetch catalog data")
http.Error(w, "Unable to fetch catalog data", http.StatusInternalServerError)
return
}
@@ -162,7 +159,7 @@ func handleCatalogList(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(out); err != nil {
- log.WithError(err).Error("Unable to encode catalog entry list")
+ logrus.WithError(err).Error("Unable to encode catalog entry list")
http.Error(w, "Unable to encode catalog meta", http.StatusInternalServerError)
return
}
@@ -184,7 +181,7 @@ func handleLog(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err = json.NewEncoder(w).Encode(logs); err != nil {
- log.WithError(err).Error("Unable to encode logs")
+ logrus.WithError(err).Error("Unable to encode logs")
http.Error(w, "Unable to encode logs", http.StatusInternalServerError)
return
}
@@ -229,7 +226,7 @@ func handleLogFeed(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
if err = feed.WriteRss(w); err != nil {
- log.WithError(err).Error("Unable to render RSS")
+ logrus.WithError(err).Error("Unable to render RSS")
http.Error(w, "Unable to render RSS", http.StatusInternalServerError)
return
}
diff --git a/go.mod b/go.mod
index 4f5daa4..c4e30a9 100644
--- a/go.mod
+++ b/go.mod
@@ -10,11 +10,15 @@ require (
github.com/antchfx/xpath v1.2.5
github.com/blang/semver/v4 v4.0.0
github.com/go-git/go-git/v5 v5.11.0
+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
github.com/gorilla/feeds v1.1.2
github.com/gorilla/mux v1.8.1
github.com/pkg/errors v0.9.1
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
+ github.com/stretchr/testify v1.9.0
+ github.com/tdewolff/minify/v2 v2.20.19
+ golang.org/x/image v0.15.0
golang.org/x/net v0.22.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.5
@@ -96,6 +100,7 @@ require (
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.19.0 // indirect
github.com/prometheus/client_model v0.6.0 // indirect
github.com/prometheus/common v0.50.0 // indirect
@@ -104,6 +109,7 @@ require (
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ github.com/tdewolff/parse/v2 v2.7.12 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
diff --git a/go.sum b/go.sum
index c1a3350..e324e9c 100644
--- a/go.sum
+++ b/go.sum
@@ -140,6 +140,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -303,8 +305,9 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -314,6 +317,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tdewolff/minify/v2 v2.20.19 h1:tX0SR0LUrIqGoLjXnkIzRSIbKJ7PaNnSENLD4CyH6Xo=
+github.com/tdewolff/minify/v2 v2.20.19/go.mod h1:ulkFoeAVWMLEyjuDz1ZIWOA31g5aWOawCFRp9R/MudM=
+github.com/tdewolff/parse/v2 v2.7.12 h1:tgavkHc2ZDEQVKy1oWxwIyh5bP4F5fEh/JmBwPP/3LQ=
+github.com/tdewolff/parse/v2 v2.7.12/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
+github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
+github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
+github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
@@ -351,6 +361,8 @@ golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2Uz
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
+golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
+golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
diff --git a/internal/badge/DejaVuSans.ttf b/internal/badge/DejaVuSans.ttf
new file mode 100644
index 0000000..5267218
Binary files /dev/null and b/internal/badge/DejaVuSans.ttf differ
diff --git a/internal/badge/assets.go b/internal/badge/assets.go
new file mode 100644
index 0000000..882c4be
--- /dev/null
+++ b/internal/badge/assets.go
@@ -0,0 +1,6 @@
+package badge
+
+import "embed"
+
+//go:embed badge.svg.tpl DejaVuSans.ttf
+var assets embed.FS
diff --git a/internal/badge/badge.go b/internal/badge/badge.go
new file mode 100644
index 0000000..0ffd589
--- /dev/null
+++ b/internal/badge/badge.go
@@ -0,0 +1,73 @@
+// Package badge contains a SVG-badge generator creating a badge from
+// title, text and color
+package badge
+
+import (
+ "bytes"
+ "html/template"
+
+ "github.com/tdewolff/minify/v2"
+ "github.com/tdewolff/minify/v2/svg"
+)
+
+const (
+ xSpacing = 8
+)
+
+const (
+ colorNameBlue = "blue"
+ colorNameBrightGreen = "brightgreen"
+ colorNameGray = "gray"
+ colorNameGreen = "green"
+ colorNameLightGray = "lightgray"
+ colorNameOrange = "orange"
+ colorNameRed = "red"
+ colorNameYellow = "yellow"
+ colorNameYellowGreen = "yellowgreen"
+)
+
+var colorList = map[string]string{
+ colorNameBlue: "007ec6",
+ colorNameBrightGreen: "4c1",
+ colorNameGray: "555",
+ colorNameGreen: "97CA00",
+ colorNameLightGray: "9f9f9f",
+ colorNameOrange: "fe7d37",
+ colorNameRed: "e05d44",
+ colorNameYellow: "dfb317",
+ colorNameYellowGreen: "a4a61d",
+}
+
+// Create renders the badge and returns the SVG in minified but
+// uncompressed form
+func Create(title, text, color string) []byte {
+ var buf bytes.Buffer
+
+ titleW, _ := calculateTextWidth(title)
+ textW, _ := calculateTextWidth(text)
+
+ width := titleW + textW + 4*xSpacing //nolint:gomnd
+
+ t, _ := assets.ReadFile("badge.svg.tpl")
+ tpl, _ := template.New("svg").Parse(string(t))
+
+ if c, ok := colorList[color]; ok {
+ color = c
+ }
+
+ _ = tpl.Execute(&buf, map[string]any{
+ "Width": width,
+ "TitleWidth": titleW + 2*xSpacing,
+ "Title": title,
+ "Text": text,
+ "TitleAnchor": titleW/2 + xSpacing,
+ "TextAnchor": titleW + textW/2 + 3*xSpacing,
+ "Color": color,
+ })
+
+ m := minify.New()
+ m.AddFunc("image/svg+xml", svg.Minify)
+
+ out, _ := m.Bytes("image/svg+xml", buf.Bytes())
+ return out
+}
diff --git a/internal/badge/badge.svg.tpl b/internal/badge/badge.svg.tpl
new file mode 100644
index 0000000..fbcd242
--- /dev/null
+++ b/internal/badge/badge.svg.tpl
@@ -0,0 +1,20 @@
+
diff --git a/internal/badge/badge_test.go b/internal/badge/badge_test.go
new file mode 100644
index 0000000..e0b3dce
--- /dev/null
+++ b/internal/badge/badge_test.go
@@ -0,0 +1,24 @@
+package badge
+
+import (
+ "html/template"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseBadgeTemplate(t *testing.T) {
+ raw, err := assets.ReadFile("badge.svg.tpl")
+ require.NoError(t, err)
+
+ _, err = template.New("svg").Parse(string(raw))
+ require.NoError(t, err)
+}
+
+func TestRenderBadge(t *testing.T) {
+ badge := Create("golang", "test", "green")
+ assert.Equal(t,
+ []byte(``),
+ badge)
+}
diff --git a/internal/badge/font.go b/internal/badge/font.go
new file mode 100644
index 0000000..37da428
--- /dev/null
+++ b/internal/badge/font.go
@@ -0,0 +1,41 @@
+package badge
+
+// Some of this code (namely the code for computing the
+// width of a string in a given font) was copied from
+// code.google.com/p/freetype-go/freetype/ which includes
+// the following copyright notice:
+// Copyright 2010 The Freetype-Go Authors. All rights reserved.
+
+import (
+ "github.com/golang/freetype/truetype"
+ "github.com/pkg/errors"
+ "golang.org/x/image/math/fixed"
+)
+
+const (
+ fontSize = 11
+)
+
+func calculateTextWidth(text string) (int, error) {
+ binFont, _ := assets.ReadFile("DejaVuSans.ttf")
+ font, err := truetype.Parse(binFont)
+ if err != nil {
+ return 0, errors.Wrap(err, "parsing truetype font")
+ }
+
+ scale := fontSize / float64(font.FUnitsPerEm())
+
+ width := 0
+ prev, hasPrev := truetype.Index(0), false
+ for _, rune := range text {
+ fUnitsPerEm := fixed.Int26_6(font.FUnitsPerEm())
+ index := font.Index(rune)
+ if hasPrev {
+ width += int(font.Kern(fUnitsPerEm, prev, index))
+ }
+ width += int(font.HMetric(fUnitsPerEm, index).AdvanceWidth)
+ prev, hasPrev = index, true
+ }
+
+ return int(float64(width) * scale), nil
+}
diff --git a/internal/badge/font_test.go b/internal/badge/font_test.go
new file mode 100644
index 0000000..b846537
--- /dev/null
+++ b/internal/badge/font_test.go
@@ -0,0 +1,27 @@
+package badge
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEmbeddedFontHash(t *testing.T) {
+ // Check the embedded font did not change
+ font, err := assets.ReadFile("DejaVuSans.ttf")
+ require.NoError(t, err)
+
+ assert.Equal(t,
+ "3fdf69cabf06049ea70a00b5919340e2ce1e6d02b0cc3c4b44fb6801bd1e0d22",
+ fmt.Sprintf("%x", sha256.Sum256(font)))
+}
+
+func TestStringLength(t *testing.T) {
+ // As the font is embedded into the source the length calculation should not change
+ w, err := calculateTextWidth("Test 123 öäüß … !@#%&")
+ require.NoError(t, err)
+ assert.Equal(t, 138, w)
+}
diff --git a/main.go b/main.go
index 490044e..88762c7 100644
--- a/main.go
+++ b/main.go
@@ -20,7 +20,6 @@ import (
var (
cfg = struct {
- BadgeGenInstance string `flag:"badge-gen-instance" default:"https://badges.fyi/" description:"Where to find the badge-gen instance to use badges from"`
BaseURL string `flag:"base-url" default:"https://example.com/" description:"Base-URL the application is reachable at"`
Config string `flag:"config,c" default:"config.yaml" description:"Configuration file with catalog entries"`
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
@@ -108,7 +107,7 @@ func main() {
router.HandleFunc("/v1/catalog/{name}/{tag}/version", handleCatalogGetVersion).Methods(http.MethodGet)
router.HandleFunc("/v1/log", handleLog).Methods(http.MethodGet)
- router.HandleFunc("/{name}/{tag}.svg", handleBadgeRedirect).Methods(http.MethodGet).Name("catalog-entry-badge")
+ router.HandleFunc("/{name}/{tag}.svg", handleBadge).Methods(http.MethodGet).Name("catalog-entry-badge")
router.HandleFunc("/{name}/{tag}/log.rss", handleLogFeed).Methods(http.MethodGet).Name("catalog-entry-rss")
router.HandleFunc("/log.rss", handleLogFeed).Methods(http.MethodGet).Name("log-rss")