v3.0.0 (New icon formats, dropped legacy URL)

This commit is contained in:
selfhst-bot
2026-01-03 07:00:06 -05:00
parent 16c7e97709
commit 986dcb1fac
7 changed files with 42 additions and 94 deletions

View File

@@ -88,10 +88,10 @@ jobs:
- name: Output image info - name: Output image info
run: | run: |
echo "🐳 Images built and pushed:" echo "Images built and pushed:"
echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /' echo "${{ steps.meta.outputs.tags }}" | sed 's/^/ - /'
echo "" echo ""
echo "🏗️ Supported architectures:" echo "Supported architectures:"
echo " - linux/amd64 (Intel/AMD x86_64)" echo " - linux/amd64"
echo " - linux/arm64 (ARM 64-bit, Apple M1, Raspberry Pi 4)" echo " - linux/arm64"
echo " - linux/arm/v7 (ARM 32-bit, Raspberry Pi 2/3)" echo " - linux/arm/v7"

View File

@@ -1,3 +1,15 @@
# v3.0.0
## Breaking Changes
The legacy custom color URL ```domain.com/icon.svg?color=000000``` is no longer supported. See the [supported methods for building URLs](https://github.com/selfhst/icons/wiki#building-links) in the project's wiki if this change impacts you.
## What's Changed
* Added support for the new AVIF and ICO formats
* Updated Go version to [v1.25](https://go.dev/doc/go1.25)
* Removed unmaintained [gorilla/mux](https://github.com/gorilla/mux) external dependency in favor of [net/http enhancements introduced in Go 1.22](https://go.dev/blog/routing-enhancements)
# v2.2.0 # v2.2.0
## What's Changed ## What's Changed

View File

@@ -1,13 +1 @@
node_modules VERSION
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.git
.gitignore
README.md
.env
.nyc_output
coverage
.vscode
.DS_Store
*.log

View File

@@ -1,17 +1,14 @@
FROM golang:1.21-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
COPY go.mod main.go ./ COPY go.mod main.go ./
RUN go mod tidy && \ RUN CGO_ENABLED=0 go build -o server .
go mod download && \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .
FROM scratch FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server COPY --from=builder /app/server /server
USER 65534:65534 USER 65534:65534

View File

@@ -1 +1 @@
2.2.0 3.0.0

View File

@@ -1,7 +1,3 @@
module selfhst-icons module selfhst-icons
go 1.21 go 1.25
require (
github.com/gorilla/mux v1.8.1
)

View File

@@ -12,8 +12,6 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/gorilla/mux"
) )
type Config struct { type Config struct {
@@ -67,7 +65,6 @@ func (c *Cache) Set(key, value string) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
// Remove oldest item if cache is full
if len(c.items) >= c.max { if len(c.items) >= c.max {
var oldestKey string var oldestKey string
var oldestTime time.Time var oldestTime time.Time
@@ -109,8 +106,8 @@ func loadConfig() *Config {
if standardFormat == "" { if standardFormat == "" {
standardFormat = "svg" standardFormat = "svg"
} }
// Validate format
if standardFormat != "svg" && standardFormat != "png" && standardFormat != "webp" { if standardFormat != "svg" && standardFormat != "png" && standardFormat != "webp" && standardFormat != "avif" && standardFormat != "ico" {
standardFormat = "svg" standardFormat = "svg"
} }
@@ -173,14 +170,12 @@ func fetchRemoteFile(url string) (string, error) {
func applySVGColor(svgContent, colorCode string) string { func applySVGColor(svgContent, colorCode string) string {
color := "#" + colorCode color := "#" + colorCode
// Replace style fill attributes
re1 := regexp.MustCompile(`style="[^"]*fill:\s*#fff[^"]*"`) re1 := regexp.MustCompile(`style="[^"]*fill:\s*#fff[^"]*"`)
svgContent = re1.ReplaceAllStringFunc(svgContent, func(match string) string { svgContent = re1.ReplaceAllStringFunc(svgContent, func(match string) string {
re2 := regexp.MustCompile(`fill:\s*#fff`) re2 := regexp.MustCompile(`fill:\s*#fff`)
return re2.ReplaceAllString(match, "fill:"+color) return re2.ReplaceAllString(match, "fill:"+color)
}) })
// Replace direct fill attributes
re3 := regexp.MustCompile(`fill="#fff"`) re3 := regexp.MustCompile(`fill="#fff"`)
svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`) svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`)
@@ -193,6 +188,10 @@ func getContentType(format string) string {
return "image/png" return "image/png"
case "webp": case "webp":
return "image/webp" return "image/webp"
case "avif":
return "image/avif"
case "ico":
return "image/x-icon"
case "svg": case "svg":
return "image/svg+xml" return "image/svg+xml"
default: default:
@@ -208,16 +207,14 @@ func getCacheKey(iconName, colorCode string) string {
} }
func handleIcon(w http.ResponseWriter, r *http.Request) { func handleIcon(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) iconName := r.PathValue("iconname")
iconName := vars["iconname"] colorCode := r.PathValue("colorcode")
colorCode := vars["colorcode"]
if iconName == "" { if iconName == "" {
http.Error(w, "Icon name is required", http.StatusBadRequest) http.Error(w, "Icon name is required", http.StatusBadRequest)
return return
} }
// Validate color if provided
if colorCode != "" && !isValidHexColor(colorCode) { if colorCode != "" && !isValidHexColor(colorCode) {
log.Printf("[ERROR] Invalid color code for icon \"%s\": %s", iconName, colorCode) log.Printf("[ERROR] Invalid color code for icon \"%s\": %s", iconName, colorCode)
http.Error(w, "Invalid color code. Use 6-digit hex without #", http.StatusBadRequest) http.Error(w, "Invalid color code. Use 6-digit hex without #", http.StatusBadRequest)
@@ -226,21 +223,17 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
cacheKey := getCacheKey(iconName, colorCode) cacheKey := getCacheKey(iconName, colorCode)
// Determine content type and format to serve
var contentType string var contentType string
var formatToServe string var formatToServe string
if colorCode != "" { if colorCode != "" {
// Always use SVG for colorization
contentType = "image/svg+xml" contentType = "image/svg+xml"
formatToServe = "svg" formatToServe = "svg"
} else { } else {
// Use standard format when no color specified
contentType = getContentType(config.StandardIconFormat) contentType = getContentType(config.StandardIconFormat)
formatToServe = config.StandardIconFormat formatToServe = config.StandardIconFormat
} }
// Check cache first
if cached, found := cache.Get(cacheKey); found { if cached, found := cache.Get(cacheKey); found {
log.Printf("[CACHE] Serving cached icon: \"%s\"%s (%s)", iconName, log.Printf("[CACHE] Serving cached icon: \"%s\"%s (%s)", iconName,
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(), func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(),
@@ -254,9 +247,7 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
var err error var err error
if config.IconSource == "local" { if config.IconSource == "local" {
// Use local volume
if colorCode != "" { if colorCode != "" {
// Try to find -light version for colorization (always SVG)
lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg") lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg")
if fileExists(lightPath) { if fileExists(lightPath) {
iconContent, err = readLocalFile(lightPath) iconContent, err = readLocalFile(lightPath)
@@ -265,12 +256,10 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
} }
} }
} else { } else {
// No color - try to serve standard format
var standardPath string var standardPath string
if formatToServe == "svg" { if formatToServe == "svg" {
standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg") standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg")
} else { } else {
// For PNG/WebP, use format-specific directories
standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe) standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe)
} }
@@ -279,7 +268,6 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
} }
} }
// Fall back to SVG if standard format not found
if iconContent == "" { if iconContent == "" {
svgPath := filepath.Join(config.LocalPath, "svg", iconName+".svg") svgPath := filepath.Join(config.LocalPath, "svg", iconName+".svg")
if fileExists(svgPath) { if fileExists(svgPath) {
@@ -289,9 +277,7 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
} }
} }
} else { } else {
// Use remote CDN
if colorCode != "" { if colorCode != "" {
// Try to find -light version for colorization (always SVG)
lightURL := config.JSDelivrURL + "/svg/" + iconName + "-light.svg" lightURL := config.JSDelivrURL + "/svg/" + iconName + "-light.svg"
if urlExists(lightURL) { if urlExists(lightURL) {
iconContent, err = fetchRemoteFile(lightURL) iconContent, err = fetchRemoteFile(lightURL)
@@ -300,7 +286,6 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
} }
} }
} else { } else {
// No color - try to serve standard format
var standardURL string var standardURL string
if formatToServe == "svg" { if formatToServe == "svg" {
standardURL = config.JSDelivrURL + "/svg/" + iconName + ".svg" standardURL = config.JSDelivrURL + "/svg/" + iconName + ".svg"
@@ -313,7 +298,6 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
} }
} }
// Fall back to SVG if standard format not found
if iconContent == "" { if iconContent == "" {
svgURL := config.JSDelivrURL + "/svg/" + iconName + ".svg" svgURL := config.JSDelivrURL + "/svg/" + iconName + ".svg"
iconContent, err = fetchRemoteFile(svgURL) iconContent, err = fetchRemoteFile(svgURL)
@@ -330,7 +314,6 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
return return
} }
// Cache the result
cache.Set(cacheKey, iconContent) cache.Set(cacheKey, iconContent)
log.Printf("[SUCCESS] Serving icon: \"%s\"%s (%s, source: %s)", iconName, log.Printf("[SUCCESS] Serving icon: \"%s\"%s (%s, source: %s)", iconName,
@@ -341,41 +324,19 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(iconContent)) w.Write([]byte(iconContent))
} }
func handleLegacyIcon(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
colorQuery := r.URL.Query().Get("color")
var colorCode string
if colorQuery != "" {
cleanColor := strings.TrimPrefix(colorQuery, "#")
if isValidHexColor(cleanColor) {
colorCode = cleanColor
}
}
// Redirect internally to new handler
vars["colorcode"] = colorCode
handleIcon(w, r)
}
func handleCustomIcon(w http.ResponseWriter, r *http.Request) { func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) filename := r.PathValue("filename")
filename := vars["filename"]
if filename == "" { if filename == "" {
http.Error(w, "Filename is required", http.StatusBadRequest) http.Error(w, "Filename is required", http.StatusBadRequest)
return return
} }
// Build the path to the custom icon file
customPath := filepath.Join("/app/icons/custom", filename) customPath := filepath.Join("/app/icons/custom", filename)
// Debug logging
log.Printf("[DEBUG] Looking for custom icon at: %s", customPath) log.Printf("[DEBUG] Looking for custom icon at: %s", customPath)
// Check if file exists
if !fileExists(customPath) { if !fileExists(customPath) {
// List directory contents for debugging
if files, err := os.ReadDir("/app/icons/custom"); err == nil { if files, err := os.ReadDir("/app/icons/custom"); err == nil {
var fileList []string var fileList []string
for _, file := range files { for _, file := range files {
@@ -390,7 +351,6 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
return return
} }
// Read the file
data, err := os.ReadFile(customPath) data, err := os.ReadFile(customPath)
if err != nil { if err != nil {
log.Printf("[ERROR] Failed to read custom icon \"%s\": %v", filename, err) log.Printf("[ERROR] Failed to read custom icon \"%s\": %v", filename, err)
@@ -398,7 +358,6 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
return return
} }
// Determine content type based on file extension
ext := strings.ToLower(filepath.Ext(filename)) ext := strings.ToLower(filepath.Ext(filename))
var contentType string var contentType string
switch ext { switch ext {
@@ -412,6 +371,8 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
contentType = "image/svg+xml" contentType = "image/svg+xml"
case ".webp": case ".webp":
contentType = "image/webp" contentType = "image/webp"
case ".avif":
contentType = "image/avif"
case ".ico": case ".ico":
contentType = "image/x-icon" contentType = "image/x-icon"
default: default:
@@ -454,20 +415,14 @@ func main() {
config = loadConfig() config = loadConfig()
cache = NewCache(config.CacheTTL, config.CacheSize) cache = NewCache(config.CacheTTL, config.CacheSize)
r := mux.NewRouter() mux := http.NewServeMux()
// Custom icons route: /custom/filename mux.HandleFunc("GET /custom/{filename}", handleCustomIcon)
r.HandleFunc("/custom/{filename}", handleCustomIcon).Methods("GET")
mux.HandleFunc("GET /{iconname}/{colorcode}", handleIcon)
// Main route: /iconname or /iconname/colorcode mux.HandleFunc("GET /{iconname}", handleIcon)
r.HandleFunc("/{iconname}", handleIcon).Methods("GET")
r.HandleFunc("/{iconname}/{colorcode}", handleIcon).Methods("GET") mux.HandleFunc("GET /", handleRoot)
// Legacy route: /iconname.svg?color=colorcode
r.HandleFunc("/{iconname}.svg", handleLegacyIcon).Methods("GET")
// Root endpoint
r.HandleFunc("/", handleRoot).Methods("GET")
log.Printf("Icon server listening on port %s", config.Port) log.Printf("Icon server listening on port %s", config.Port)
log.Printf("Icon source: %s", func() string { log.Printf("Icon source: %s", func() string {
@@ -478,5 +433,5 @@ func main() {
}()) }())
log.Printf("Cache settings: TTL %ds, Max %d items", int(config.CacheTTL.Seconds()), config.CacheSize) log.Printf("Cache settings: TTL %ds, Max %d items", int(config.CacheTTL.Seconds()), config.CacheSize)
log.Fatal(http.ListenAndServe(":"+config.Port, r)) log.Fatal(http.ListenAndServe(":"+config.Port, mux))
} }