v4.0.0 (Icon extensions in URLs, hybrid option)

This commit is contained in:
selfhst-bot
2026-03-08 13:27:45 -04:00
parent 92e3de35c8
commit fd1845d252
5 changed files with 437 additions and 191 deletions

View File

@@ -1,9 +1,42 @@
# v4.0.0
This release initially set out to address a request to support icon extensions in URLs, but quickly grew into a number of under the hood optimization/security fixes and a new ```hybrid``` mode that allows users with local collections to set remote icons as a fallback.
## Breaking Changes
The ```STANDARD_ICON_FORMAT``` variable has been deprecated and users now have the option to specify their desired icon extension in the URL. If an extension is not specified, the server will default to WebP.
Users should review the updated methods for referencing icons in the [project's wiki](https://github.com/selfhst/icons/wiki#building-links) when upgrading.
## What's Changed
* [Feature] Added support for icon extensions in the URL (```example.svg```, ```example.png```, etc.) ([#718](https://github.com/selfhst/icons/issues/718), [#737](https://github.com/selfhst/icons/issues/737))
* [Feature] Added support for custom color URL parameters (```?color=2d2d2d```) as an alternative to paths (```icon/2d2d2d```) ([#737](https://github.com/selfhst/icons/issues/737))
* [Feature] Added a new ```hybrid``` source option that prioritizes local collections and falls back to remote when missing
* [Feature] Added an optional ```LOG_LEVEL``` variable to control log verbosity
* [Feature] Added an optional ```CORS_ALLOWED_ORIGINS``` variable (defaults to all or ```*```)
* [Feature] Added optional variables for configurable cache times (```CACHE_TTL```) and size (```CACHE_SIZE```)
* [Feature] Added an optional ```REMOTE_TIMEOUT``` variable to prevent the server from hanging when requests take too long (default: ```10``` seconds)
* Configured WebP as the global default when no extension is specified or an unsupported extension is requested
* Added Docker health checks via the `-healthcheck` flag to accommodate scratch's lack of tooling (curl/wget)
* Added startup validations and logging to warn users of misconfigured variables
* Added graceful shutdowns to allow in-flight requests to complete before exiting
* Added validations to properly identify ```#fff``` and ```#ffffff``` when colorizing icons
* Added request completion time to log messages
* Added case normalization checks to prevent the server from caching the same icon multiple times
* Added GZIP compression for SVG requests with proper proxy headers
* Added a security check for icons names with ```/```, ```\```, or ```..``` (attempted path traversal)
* Added an ```X-Cache``` response header to signal cache status without having to view container logs
* Added a process to regularly clean stale cache entries
* Removed redundant file existence checks for local icons
* Removed unnecessary directory scans when a custom icon is not found (initially included for debugging purposes)
# v3.2.0 # v3.2.0
## What's Changed ## What's Changed
* [Feature] Added PRIMARY_COLOR variable to easily apply a single custom color to all icons (see Wiki for additional details) * [Feature] Added ```PRIMARY_COLOR``` variable to easily apply a single custom color to all icons (see Wiki for additional details)
* [Feature] Added REMOTE_URL to allow users to serve icons from their own remote sources (see Wiki for additional details) ([#690](https://github.com/selfhst/icons/issues/690)) * [Feature] Added ```REMOTE_URL``` to allow users to serve icons from their own remote sources (see Wiki for additional details) ([#690](https://github.com/selfhst/icons/issues/690))
* Reduced remote icon load time by removing redundant existence checks * Reduced remote icon load time by removing redundant existence checks
* Updated Go version to [v1.26](https://go.dev/doc/go1.26) * Updated Go version to [v1.26](https://go.dev/doc/go1.26)

View File

@@ -4,7 +4,7 @@ WORKDIR /app
COPY go.mod main.go ./ COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o server . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server .
FROM scratch FROM scratch
@@ -15,4 +15,7 @@ USER 65534:65534
EXPOSE 4050 EXPOSE 4050
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/server", "-healthcheck"]
ENTRYPOINT ["/server"] ENTRYPOINT ["/server"]

View File

@@ -1 +1 @@
3.2.0 4.0.0

View File

@@ -1,3 +1,3 @@
module selfhst-icons module github.com/selfhst/icons
go 1.26 go 1.26

View File

@@ -1,28 +1,41 @@
package main package main
import ( import (
"encoding/json" "context"
"fmt" "fmt"
"hash/fnv"
"compress/gzip"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
) )
const (
logLevelDebug = 0
logLevelInfo = 1
logLevelError = 2
)
type Config struct { type Config struct {
Port string Port string
IconSource string IconSource string
RemoteURL string RemoteURL string
LocalPath string LocalPath string
StandardIconFormat string PrimaryColor string
PrimaryColor string CacheTTL time.Duration
CacheTTL time.Duration CacheSize int
CacheSize int RemoteTimeout time.Duration
CORSOrigins []string
LogLevel int
} }
type CacheItem struct { type CacheItem struct {
@@ -47,14 +60,19 @@ func NewCache(ttl time.Duration, maxSize int) *Cache {
func (c *Cache) Get(key string) (string, bool) { func (c *Cache) Get(key string) (string, bool) {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock()
item, exists := c.items[key] item, exists := c.items[key]
c.mutex.RUnlock()
if !exists { if !exists {
return "", false return "", false
} }
if time.Since(item.Timestamp) > c.ttl { if time.Since(item.Timestamp) > c.ttl {
c.mutex.Lock()
if current, exists := c.items[key]; exists && time.Since(current.Timestamp) > c.ttl {
delete(c.items, key)
}
c.mutex.Unlock()
return "", false return "", false
} }
@@ -87,10 +105,40 @@ func (c *Cache) Set(key, value string) {
} }
var ( var (
config *Config config *Config
cache *Cache cache *Cache
httpClient *http.Client
) )
var (
hexColorRe = regexp.MustCompile(`^[0-9A-Fa-f]{6}$`)
reFillStyle = regexp.MustCompile(`style="[^"]*fill:\s*#fff(?:fff)?[^"]*"`)
reFillInner = regexp.MustCompile(`fill:\s*#fff(?:fff)?`)
reFillAttr = regexp.MustCompile(`fill="#fff(?:fff)?"`)
reStopStyle = regexp.MustCompile(`style="[^"]*stop-color:\s*#fff(?:fff)?[^"]*"`)
reStopInner = regexp.MustCompile(`stop-color:\s*#fff(?:fff)?`)
reStopAttr = regexp.MustCompile(`stop-color="#fff(?:fff)?"`)
)
func logf(level int, format string, args ...any) {
if level >= config.LogLevel {
log.Printf(format, args...)
}
}
func formatDuration(d time.Duration) string {
if d >= time.Second {
return fmt.Sprintf("%.2fs", d.Seconds())
}
return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond))
}
func computeETag(content string) string {
h := fnv.New64a()
h.Write([]byte(content))
return fmt.Sprintf(`"%x"`, h.Sum64())
}
func loadConfig() *Config { func loadConfig() *Config {
port := os.Getenv("PORT") port := os.Getenv("PORT")
if port == "" { if port == "" {
@@ -102,15 +150,6 @@ func loadConfig() *Config {
iconSource = "remote" iconSource = "remote"
} }
standardFormat := os.Getenv("STANDARD_ICON_FORMAT")
if standardFormat == "" {
standardFormat = "svg"
}
if standardFormat != "svg" && standardFormat != "png" && standardFormat != "webp" && standardFormat != "avif" && standardFormat != "ico" {
standardFormat = "svg"
}
remoteURL := os.Getenv("REMOTE_URL") remoteURL := os.Getenv("REMOTE_URL")
if remoteURL == "" { if remoteURL == "" {
remoteURL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main" remoteURL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main"
@@ -118,29 +157,132 @@ func loadConfig() *Config {
primaryColor := strings.TrimPrefix(os.Getenv("PRIMARY_COLOR"), "#") primaryColor := strings.TrimPrefix(os.Getenv("PRIMARY_COLOR"), "#")
cacheTTL := time.Hour
if v := os.Getenv("CACHE_TTL"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cacheTTL = time.Duration(n) * time.Second
} else {
log.Printf("[WARN] Invalid CACHE_TTL value \"%s\", using default (3600)", v)
}
}
cacheSize := 500
if v := os.Getenv("CACHE_SIZE"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
cacheSize = n
} else {
log.Printf("[WARN] Invalid CACHE_SIZE value \"%s\", using default (500)", v)
}
}
remoteTimeout := 10 * time.Second
if v := os.Getenv("REMOTE_TIMEOUT"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
remoteTimeout = time.Duration(n) * time.Second
} else {
log.Printf("[WARN] Invalid REMOTE_TIMEOUT value \"%s\", using default (10)", v)
}
}
corsAllowedOrigins := os.Getenv("CORS_ALLOWED_ORIGINS")
if corsAllowedOrigins == "" {
corsAllowedOrigins = "*"
}
var corsOrigins []string
for _, o := range strings.Split(corsAllowedOrigins, ",") {
if trimmed := strings.TrimSpace(o); trimmed != "" {
corsOrigins = append(corsOrigins, trimmed)
}
}
logLevel := logLevelInfo
if v := os.Getenv("LOG_LEVEL"); v != "" {
switch strings.ToLower(v) {
case "debug":
logLevel = logLevelDebug
case "info":
logLevel = logLevelInfo
case "error":
logLevel = logLevelError
default:
log.Printf("[WARN] Invalid LOG_LEVEL value \"%s\", using default (info)", v)
}
}
return &Config{ return &Config{
Port: port, Port: port,
IconSource: iconSource, IconSource: iconSource,
RemoteURL: remoteURL, RemoteURL: remoteURL,
LocalPath: "/app/icons", LocalPath: "/app/icons",
StandardIconFormat: standardFormat, PrimaryColor: primaryColor,
PrimaryColor: primaryColor, CacheTTL: cacheTTL,
CacheTTL: time.Hour, CacheSize: cacheSize,
CacheSize: 500, RemoteTimeout: remoteTimeout,
CORSOrigins: corsOrigins,
LogLevel: logLevel,
}
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if len(config.CORSOrigins) == 1 && config.CORSOrigins[0] == "*" {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
origin := r.Header.Get("Origin")
for _, allowed := range config.CORSOrigins {
if origin == allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
break
}
}
}
next.ServeHTTP(w, r)
})
}
func validateConfig(cfg *Config) {
if cfg.IconSource != "local" && cfg.IconSource != "remote" && cfg.IconSource != "hybrid" {
log.Fatalf("[ERROR] Invalid ICON_SOURCE \"%s\": must be \"local\", \"remote\", or \"hybrid\"", cfg.IconSource)
}
if cfg.IconSource == "local" || cfg.IconSource == "hybrid" {
info, err := os.Stat(cfg.LocalPath)
if err != nil {
log.Fatalf("[ERROR] Icon path \"%s\" is not accessible: %v", cfg.LocalPath, err)
}
if !info.IsDir() {
log.Fatalf("[ERROR] Icon path \"%s\" is not a directory", cfg.LocalPath)
}
}
if cfg.PrimaryColor != "" && !isValidHexColor(cfg.PrimaryColor) {
log.Fatalf("[ERROR] PRIMARY_COLOR \"%s\" is not a valid 6-digit hex color", cfg.PrimaryColor)
}
for _, origin := range cfg.CORSOrigins {
if origin != "*" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") {
log.Printf("[WARN] CORS_ALLOWED_ORIGINS entry \"%s\" is missing a scheme — did you mean \"https://%s\"?", origin, origin)
}
} }
} }
func isValidHexColor(color string) bool { func isValidHexColor(color string) bool {
matched, _ := regexp.MatchString("^[0-9A-Fa-f]{6}$", color) return hexColorRe.MatchString(color)
return matched
} }
func fileExists(path string) bool { func parseIconName(iconName string) (string, string) {
_, err := os.Stat(path) ext := strings.ToLower(filepath.Ext(iconName))
return err == nil if ext != "" {
format := strings.TrimPrefix(ext, ".")
switch format {
case "svg", "png", "webp", "avif", "ico":
return strings.TrimSuffix(iconName, filepath.Ext(iconName)), format
default:
logf(logLevelError, "[WARN] Unrecognized extension \"%s\" in icon name \"%s\", serving webp instead", ext, iconName)
return strings.TrimSuffix(iconName, filepath.Ext(iconName)), "webp"
}
}
return iconName, "webp"
} }
func readLocalFile(path string) (string, error) { func readLocalFile(path string) (string, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -150,7 +292,7 @@ func readLocalFile(path string) (string, error) {
} }
func fetchRemoteFile(url string) (string, error) { func fetchRemoteFile(url string) (string, error) {
resp, err := http.Get(url) resp, err := httpClient.Get(url)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -171,28 +313,34 @@ func applySVGColor(svgContent, colorCode string) string {
color := "#" + colorCode color := "#" + colorCode
// Replace fill:#fff // Replace fill:#fff
re1 := regexp.MustCompile(`style="[^"]*fill:\s*#fff[^"]*"`) svgContent = reFillStyle.ReplaceAllStringFunc(svgContent, func(match string) string {
svgContent = re1.ReplaceAllStringFunc(svgContent, func(match string) string { return reFillInner.ReplaceAllString(match, "fill:"+color)
re2 := regexp.MustCompile(`fill:\s*#fff`)
return re2.ReplaceAllString(match, "fill:"+color)
}) })
svgContent = reFillAttr.ReplaceAllString(svgContent, `fill="`+color+`"`)
re3 := regexp.MustCompile(`fill="#fff"`)
svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`)
// Replace stop-color:#fff in gradients // Replace stop-color:#fff in gradients
re4 := regexp.MustCompile(`style="[^"]*stop-color:\s*#fff[^"]*"`) svgContent = reStopStyle.ReplaceAllStringFunc(svgContent, func(match string) string {
svgContent = re4.ReplaceAllStringFunc(svgContent, func(match string) string { return reStopInner.ReplaceAllString(match, "stop-color:"+color)
re5 := regexp.MustCompile(`stop-color:\s*#fff`)
return re5.ReplaceAllString(match, "stop-color:"+color)
}) })
svgContent = reStopAttr.ReplaceAllString(svgContent, `stop-color="`+color+`"`)
re6 := regexp.MustCompile(`stop-color="#fff"`)
svgContent = re6.ReplaceAllString(svgContent, `stop-color="`+color+`"`)
return svgContent return svgContent
} }
func serveContent(w http.ResponseWriter, r *http.Request, contentType, content string) {
if contentType == "image/svg+xml" && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
if err == nil {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
defer gz.Close()
io.WriteString(gz, content)
return
}
}
io.WriteString(w, content)
}
func getContentType(format string) string { func getContentType(format string) string {
switch format { switch format {
case "png": case "png":
@@ -205,8 +353,12 @@ func getContentType(format string) string {
return "image/x-icon" return "image/x-icon"
case "svg": case "svg":
return "image/svg+xml" return "image/svg+xml"
case "jpg", "jpeg":
return "image/jpeg"
case "gif":
return "image/gif"
default: default:
return "image/svg+xml" return ""
} }
} }
@@ -218,6 +370,7 @@ func getCacheKey(iconName, colorCode string) string {
} }
func handleIcon(w http.ResponseWriter, r *http.Request) { func handleIcon(w http.ResponseWriter, r *http.Request) {
start := time.Now()
iconName := r.PathValue("iconname") iconName := r.PathValue("iconname")
colorCode := r.PathValue("colorcode") colorCode := r.PathValue("colorcode")
@@ -226,6 +379,19 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
return return
} }
baseName, format := parseIconName(iconName)
baseName = strings.ToLower(baseName)
if strings.Contains(baseName, "..") || strings.Contains(baseName, "/") || strings.Contains(baseName, "\\") {
logf(logLevelError, "[ERROR] Invalid icon name, path traversal attempt: \"%s\"", iconName)
http.Error(w, "Invalid icon name", http.StatusBadRequest)
return
}
if colorCode == "" {
colorCode = strings.TrimPrefix(r.URL.Query().Get("color"), "#")
}
primaryFallback := false primaryFallback := false
if colorCode == "primary" { if colorCode == "primary" {
if config.PrimaryColor == "" { if config.PrimaryColor == "" {
@@ -235,13 +401,11 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
} }
if colorCode != "" && !isValidHexColor(colorCode) { if colorCode != "" && !isValidHexColor(colorCode) {
log.Printf("[ERROR] Invalid color code for icon \"%s\": %s", iconName, colorCode) logf(logLevelError, "[ERROR] Invalid color code for icon \"%s\": %s", baseName, 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)
return return
} }
cacheKey := getCacheKey(iconName, colorCode)
var contentType string var contentType string
var formatToServe string var formatToServe string
@@ -249,110 +413,126 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
contentType = "image/svg+xml" contentType = "image/svg+xml"
formatToServe = "svg" formatToServe = "svg"
} else { } else {
contentType = getContentType(config.StandardIconFormat) formatToServe = format
formatToServe = config.StandardIconFormat contentType = getContentType(formatToServe)
}
cacheKey := getCacheKey(baseName+"."+formatToServe, colorCode)
var colorSuffix string
if colorCode != "" {
colorSuffix = " with color " + colorCode
} }
if cached, found := cache.Get(cacheKey); found { if cached, found := cache.Get(cacheKey); found {
log.Printf("[CACHE] Serving cached icon: \"%s\"%s (%s)", iconName, logf(logLevelDebug, "[CACHE] Serving cached icon: \"%s\"%s (%s) %v", baseName, colorSuffix, formatToServe, formatDuration(time.Since(start)))
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(), etag := computeETag(cached)
formatToServe) if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Write([]byte(cached)) w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("ETag", etag)
w.Header().Set("X-Cache", "HIT")
serveContent(w, r, contentType, cached)
return return
} }
var iconContent string var iconContent string
var err error var servedFrom string
if config.IconSource == "local" { if config.IconSource == "local" || config.IconSource == "hybrid" {
if colorCode != "" { if colorCode != "" {
lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg") lightPath := filepath.Join(config.LocalPath, "svg", baseName+"-light.svg")
if fileExists(lightPath) { if content, err := readLocalFile(lightPath); err == nil {
iconContent, err = readLocalFile(lightPath) iconContent = applySVGColor(content, colorCode)
if err == nil { servedFrom = "local"
iconContent = applySVGColor(iconContent, colorCode)
}
} }
} else { } else {
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", baseName+".svg")
} else { } else {
standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe) standardPath = filepath.Join(config.LocalPath, formatToServe, baseName+"."+formatToServe)
} }
iconContent, _ = readLocalFile(standardPath)
if fileExists(standardPath) { if iconContent == "" && formatToServe != "webp" {
iconContent, err = readLocalFile(standardPath) webpPath := filepath.Join(config.LocalPath, "webp", baseName+".webp")
if content, err := readLocalFile(webpPath); err == nil {
iconContent = content
contentType = "image/webp"
formatToServe = "webp"
}
}
if iconContent != "" {
servedFrom = "local"
} }
} }
}
if iconContent == "" { if iconContent == "" && (config.IconSource == "remote" || config.IconSource == "hybrid") {
svgPath := filepath.Join(config.LocalPath, "svg", iconName+".svg")
if fileExists(svgPath) {
iconContent, err = readLocalFile(svgPath)
contentType = "image/svg+xml"
formatToServe = "svg"
}
}
} else {
if colorCode != "" { if colorCode != "" {
lightURL := config.RemoteURL + "/svg/" + iconName + "-light.svg" lightURL := config.RemoteURL + "/svg/" + baseName + "-light.svg"
iconContent, err = fetchRemoteFile(lightURL) if content, err := fetchRemoteFile(lightURL); err == nil {
if err == nil { iconContent = applySVGColor(content, colorCode)
iconContent = applySVGColor(iconContent, colorCode) servedFrom = "remote"
} else {
iconContent = ""
err = nil
} }
} else { } else {
var standardURL string var standardURL string
if formatToServe == "svg" { if formatToServe == "svg" {
standardURL = config.RemoteURL + "/svg/" + iconName + ".svg" standardURL = config.RemoteURL + "/svg/" + baseName + ".svg"
} else { } else {
standardURL = config.RemoteURL + "/" + formatToServe + "/" + iconName + "." + formatToServe standardURL = config.RemoteURL + "/" + formatToServe + "/" + baseName + "." + formatToServe
} }
iconContent, _ = fetchRemoteFile(standardURL)
iconContent, err = fetchRemoteFile(standardURL) if iconContent == "" && formatToServe != "webp" {
if err != nil { webpURL := config.RemoteURL + "/webp/" + baseName + ".webp"
iconContent = "" if content, err := fetchRemoteFile(webpURL); err == nil {
err = nil iconContent = content
contentType = "image/webp"
formatToServe = "webp"
}
}
if iconContent != "" {
servedFrom = "remote"
} }
}
if iconContent == "" {
svgURL := config.RemoteURL + "/svg/" + iconName + ".svg"
iconContent, err = fetchRemoteFile(svgURL)
contentType = "image/svg+xml"
formatToServe = "svg"
} }
} }
if iconContent == "" || err != nil { if iconContent == "" {
log.Printf("[ERROR] Icon not found: \"%s\"%s (source: %s)", iconName, logf(logLevelError, "[ERROR] Icon not found: \"%s\"%s (source: %s) %v", baseName, colorSuffix, config.IconSource, formatDuration(time.Since(start)))
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(),
config.IconSource)
http.Error(w, "Icon not found", http.StatusNotFound) http.Error(w, "Icon not found", http.StatusNotFound)
return return
} }
cacheKey = getCacheKey(baseName+"."+formatToServe, colorCode)
cache.Set(cacheKey, iconContent) cache.Set(cacheKey, iconContent)
log.Printf("[%s] Serving icon: \"%s\"%s (%s, source: %s)", level := "SUCCESS"
func() string { if primaryFallback { return "WARN" } else { return "SUCCESS" } }(), detail := colorSuffix
iconName, if primaryFallback {
func() string { level = "WARN"
if colorCode != "" { return " with color " + colorCode } detail = " (PRIMARY_COLOR not set, using default format)"
if primaryFallback { return " (PRIMARY_COLOR not set, using standard format)" } }
return "" logf(logLevelInfo, "[%s] Serving icon: \"%s\"%s (%s, source: %s) %v", level, baseName, detail, formatToServe, servedFrom, formatDuration(time.Since(start)))
}(),
formatToServe, config.IconSource)
etag := computeETag(iconContent)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Write([]byte(iconContent)) w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("ETag", etag)
w.Header().Set("X-Cache", "MISS")
serveContent(w, r, contentType, iconContent)
} }
func handleCustomIcon(w http.ResponseWriter, r *http.Request) { func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
start := time.Now()
filename := r.PathValue("filename") filename := r.PathValue("filename")
if filename == "" { if filename == "" {
@@ -360,91 +540,91 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
return return
} }
customPath := filepath.Join("/app/icons/custom", filename) if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
logf(logLevelError, "[ERROR] Invalid custom icon filename, path traversal attempt: \"%s\"", filename)
http.Error(w, "Invalid filename", http.StatusBadRequest)
return
}
log.Printf("[DEBUG] Looking for custom icon at: %s", customPath) filename = strings.ToLower(filename)
if !fileExists(customPath) { customPath := filepath.Join(config.LocalPath, "custom", filename)
if files, err := os.ReadDir("/app/icons/custom"); err == nil {
var fileList []string stat, err := os.Stat(customPath)
for _, file := range files { if err != nil {
fileList = append(fileList, file.Name()) if os.IsNotExist(err) {
} logf(logLevelError, "[ERROR] Custom icon not found: \"%s\" %v", filename, formatDuration(time.Since(start)))
log.Printf("[DEBUG] Files in /app/icons/custom: %v", fileList) http.Error(w, "Custom icon not found", http.StatusNotFound)
} else { } else {
log.Printf("[DEBUG] Failed to read /app/icons/custom directory: %v", err) logf(logLevelError, "[ERROR] Failed to read custom icon \"%s\": %v (%v)", filename, err, formatDuration(time.Since(start)))
http.Error(w, "Failed to read custom icon", http.StatusInternalServerError)
} }
log.Printf("[ERROR] Custom icon not found: \"%s\" at path: %s", filename, customPath) return
http.Error(w, "Custom icon not found", http.StatusNotFound) }
etag := fmt.Sprintf(`"%d-%d"`, stat.ModTime().Unix(), stat.Size())
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
ext := strings.ToLower(filepath.Ext(filename))
contentType := getContentType(strings.TrimPrefix(ext, "."))
if contentType == "" {
contentType = "application/octet-stream"
}
cacheKey := "custom:" + filename + ":" + etag
if cached, found := cache.Get(cacheKey); found {
logf(logLevelDebug, "[CACHE] Serving cached custom icon: \"%s\" %v", filename, formatDuration(time.Since(start)))
w.Header().Set("Content-Type", contentType)
w.Header().Set("ETag", etag)
w.Header().Set("X-Cache", "HIT")
serveContent(w, r, contentType, cached)
return return
} }
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) logf(logLevelError, "[ERROR] Failed to read custom icon \"%s\": %v (%v)", filename, err, formatDuration(time.Since(start)))
http.Error(w, "Failed to read custom icon", http.StatusInternalServerError) http.Error(w, "Failed to read custom icon", http.StatusInternalServerError)
return return
} }
ext := strings.ToLower(filepath.Ext(filename)) cache.Set(cacheKey, string(data))
var contentType string
switch ext {
case ".png":
contentType = "image/png"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
case ".gif":
contentType = "image/gif"
case ".svg":
contentType = "image/svg+xml"
case ".webp":
contentType = "image/webp"
case ".avif":
contentType = "image/avif"
case ".ico":
contentType = "image/x-icon"
default:
contentType = "application/octet-stream"
}
log.Printf("[SUCCESS] Serving custom icon: \"%s\" (%s)", filename, contentType) logf(logLevelInfo, "[SUCCESS] Serving custom icon: \"%s\" (%s) %v", filename, contentType, formatDuration(time.Since(start)))
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Write(data) w.Header().Set("ETag", etag)
} w.Header().Set("X-Cache", "MISS")
serveContent(w, r, contentType, string(data))
func handleRoot(w http.ResponseWriter, r *http.Request) {
configInfo := map[string]interface{}{
"server": "Self-hosted icon server",
"urlFormat": "https://subdomain.example.com/iconname/colorcode",
"features": map[string]interface{}{
"iconSource": func() string {
if config.IconSource == "local" {
return "Local volume"
}
return "Remote CDN"
}(),
"standardFormat": config.StandardIconFormat,
"caching": fmt.Sprintf("TTL: %ds, Max items: %d", int(config.CacheTTL.Seconds()), config.CacheSize),
"baseUrl": func() string {
if config.IconSource == "local" {
return config.LocalPath
}
return config.RemoteURL
}(),
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(configInfo)
} }
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
port := os.Getenv("PORT")
if port == "" {
port = "4050"
}
resp, err := http.Get("http://localhost:" + port + "/health")
if err != nil || resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}
config = loadConfig() config = loadConfig()
validateConfig(config)
cache = NewCache(config.CacheTTL, config.CacheSize) cache = NewCache(config.CacheTTL, config.CacheSize)
httpClient = &http.Client{Timeout: config.RemoteTimeout}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /custom/{filename}", handleCustomIcon) mux.HandleFunc("GET /custom/{filename}", handleCustomIcon)
// Suppress favicon load error message in logs when viewing via browser // Suppress favicon load error message in logs when viewing via browser
@@ -455,16 +635,46 @@ func main() {
mux.HandleFunc("GET /{iconname}/{colorcode}", handleIcon) mux.HandleFunc("GET /{iconname}/{colorcode}", handleIcon)
mux.HandleFunc("GET /{iconname}", handleIcon) mux.HandleFunc("GET /{iconname}", handleIcon)
mux.HandleFunc("GET /", handleRoot)
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 {
if config.IconSource == "local" { switch config.IconSource {
case "local":
return "Local volume" return "Local volume"
case "hybrid":
return "Hybrid (local with remote fallback: " + config.RemoteURL + ")"
default:
return "Remote: " + config.RemoteURL
} }
return "Remote: " + config.RemoteURL
}()) }())
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.Printf("Log level: %s", []string{"debug", "info", "error"}[config.LogLevel])
log.Fatal(http.ListenAndServe(":"+config.Port, mux)) server := &http.Server{
Addr: ":" + config.Port,
Handler: corsMiddleware(mux),
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
<-quit
log.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server stopped. Bye for now!")
} }