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
## 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 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 ```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))
* Reduced remote icon load time by removing redundant existence checks
* 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 ./
RUN CGO_ENABLED=0 go build -o server .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o server .
FROM scratch
@@ -15,4 +15,7 @@ USER 65534:65534
EXPOSE 4050
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/server", "-healthcheck"]
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

View File

@@ -1,28 +1,41 @@
package main
import (
"encoding/json"
"context"
"fmt"
"hash/fnv"
"compress/gzip"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const (
logLevelDebug = 0
logLevelInfo = 1
logLevelError = 2
)
type Config struct {
Port string
IconSource string
RemoteURL string
LocalPath string
StandardIconFormat string
PrimaryColor string
CacheTTL time.Duration
CacheSize int
Port string
IconSource string
RemoteURL string
LocalPath string
PrimaryColor string
CacheTTL time.Duration
CacheSize int
RemoteTimeout time.Duration
CORSOrigins []string
LogLevel int
}
type CacheItem struct {
@@ -47,14 +60,19 @@ func NewCache(ttl time.Duration, maxSize int) *Cache {
func (c *Cache) Get(key string) (string, bool) {
c.mutex.RLock()
defer c.mutex.RUnlock()
item, exists := c.items[key]
c.mutex.RUnlock()
if !exists {
return "", false
}
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
}
@@ -87,10 +105,40 @@ func (c *Cache) Set(key, value string) {
}
var (
config *Config
cache *Cache
config *Config
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 {
port := os.Getenv("PORT")
if port == "" {
@@ -102,15 +150,6 @@ func loadConfig() *Config {
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")
if remoteURL == "" {
remoteURL = "https://cdn.jsdelivr.net/gh/selfhst/icons@main"
@@ -118,29 +157,132 @@ func loadConfig() *Config {
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{
Port: port,
IconSource: iconSource,
RemoteURL: remoteURL,
LocalPath: "/app/icons",
StandardIconFormat: standardFormat,
PrimaryColor: primaryColor,
CacheTTL: time.Hour,
CacheSize: 500,
Port: port,
IconSource: iconSource,
RemoteURL: remoteURL,
LocalPath: "/app/icons",
PrimaryColor: primaryColor,
CacheTTL: cacheTTL,
CacheSize: cacheSize,
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 {
matched, _ := regexp.MatchString("^[0-9A-Fa-f]{6}$", color)
return matched
return hexColorRe.MatchString(color)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
func parseIconName(iconName string) (string, string) {
ext := strings.ToLower(filepath.Ext(iconName))
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) {
data, err := os.ReadFile(path)
if err != nil {
@@ -150,7 +292,7 @@ func readLocalFile(path string) (string, error) {
}
func fetchRemoteFile(url string) (string, error) {
resp, err := http.Get(url)
resp, err := httpClient.Get(url)
if err != nil {
return "", err
}
@@ -171,28 +313,34 @@ func applySVGColor(svgContent, colorCode string) string {
color := "#" + colorCode
// Replace fill:#fff
re1 := regexp.MustCompile(`style="[^"]*fill:\s*#fff[^"]*"`)
svgContent = re1.ReplaceAllStringFunc(svgContent, func(match string) string {
re2 := regexp.MustCompile(`fill:\s*#fff`)
return re2.ReplaceAllString(match, "fill:"+color)
svgContent = reFillStyle.ReplaceAllStringFunc(svgContent, func(match string) string {
return reFillInner.ReplaceAllString(match, "fill:"+color)
})
re3 := regexp.MustCompile(`fill="#fff"`)
svgContent = re3.ReplaceAllString(svgContent, `fill="`+color+`"`)
svgContent = reFillAttr.ReplaceAllString(svgContent, `fill="`+color+`"`)
// Replace stop-color:#fff in gradients
re4 := regexp.MustCompile(`style="[^"]*stop-color:\s*#fff[^"]*"`)
svgContent = re4.ReplaceAllStringFunc(svgContent, func(match string) string {
re5 := regexp.MustCompile(`stop-color:\s*#fff`)
return re5.ReplaceAllString(match, "stop-color:"+color)
svgContent = reStopStyle.ReplaceAllStringFunc(svgContent, func(match string) string {
return reStopInner.ReplaceAllString(match, "stop-color:"+color)
})
re6 := regexp.MustCompile(`stop-color="#fff"`)
svgContent = re6.ReplaceAllString(svgContent, `stop-color="`+color+`"`)
svgContent = reStopAttr.ReplaceAllString(svgContent, `stop-color="`+color+`"`)
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 {
switch format {
case "png":
@@ -205,8 +353,12 @@ func getContentType(format string) string {
return "image/x-icon"
case "svg":
return "image/svg+xml"
case "jpg", "jpeg":
return "image/jpeg"
case "gif":
return "image/gif"
default:
return "image/svg+xml"
return ""
}
}
@@ -218,6 +370,7 @@ func getCacheKey(iconName, colorCode string) string {
}
func handleIcon(w http.ResponseWriter, r *http.Request) {
start := time.Now()
iconName := r.PathValue("iconname")
colorCode := r.PathValue("colorcode")
@@ -226,6 +379,19 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
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
if colorCode == "primary" {
if config.PrimaryColor == "" {
@@ -235,124 +401,138 @@ func handleIcon(w http.ResponseWriter, r *http.Request) {
}
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)
return
}
cacheKey := getCacheKey(iconName, colorCode)
var contentType string
var formatToServe string
if colorCode != "" {
contentType = "image/svg+xml"
formatToServe = "svg"
} else {
contentType = getContentType(config.StandardIconFormat)
formatToServe = config.StandardIconFormat
formatToServe = format
contentType = getContentType(formatToServe)
}
cacheKey := getCacheKey(baseName+"."+formatToServe, colorCode)
var colorSuffix string
if colorCode != "" {
colorSuffix = " with color " + colorCode
}
if cached, found := cache.Get(cacheKey); found {
log.Printf("[CACHE] Serving cached icon: \"%s\"%s (%s)", iconName,
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(),
formatToServe)
logf(logLevelDebug, "[CACHE] Serving cached icon: \"%s\"%s (%s) %v", baseName, colorSuffix, formatToServe, formatDuration(time.Since(start)))
etag := computeETag(cached)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
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
}
var iconContent string
var err error
var servedFrom string
if config.IconSource == "local" {
if config.IconSource == "local" || config.IconSource == "hybrid" {
if colorCode != "" {
lightPath := filepath.Join(config.LocalPath, "svg", iconName+"-light.svg")
if fileExists(lightPath) {
iconContent, err = readLocalFile(lightPath)
if err == nil {
iconContent = applySVGColor(iconContent, colorCode)
}
lightPath := filepath.Join(config.LocalPath, "svg", baseName+"-light.svg")
if content, err := readLocalFile(lightPath); err == nil {
iconContent = applySVGColor(content, colorCode)
servedFrom = "local"
}
} else {
var standardPath string
if formatToServe == "svg" {
standardPath = filepath.Join(config.LocalPath, "svg", iconName+".svg")
standardPath = filepath.Join(config.LocalPath, "svg", baseName+".svg")
} else {
standardPath = filepath.Join(config.LocalPath, formatToServe, iconName+"."+formatToServe)
standardPath = filepath.Join(config.LocalPath, formatToServe, baseName+"."+formatToServe)
}
if fileExists(standardPath) {
iconContent, err = readLocalFile(standardPath)
iconContent, _ = readLocalFile(standardPath)
if iconContent == "" && formatToServe != "webp" {
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 == "" {
svgPath := filepath.Join(config.LocalPath, "svg", iconName+".svg")
if fileExists(svgPath) {
iconContent, err = readLocalFile(svgPath)
contentType = "image/svg+xml"
formatToServe = "svg"
}
}
} else {
}
if iconContent == "" && (config.IconSource == "remote" || config.IconSource == "hybrid") {
if colorCode != "" {
lightURL := config.RemoteURL + "/svg/" + iconName + "-light.svg"
iconContent, err = fetchRemoteFile(lightURL)
if err == nil {
iconContent = applySVGColor(iconContent, colorCode)
} else {
iconContent = ""
err = nil
lightURL := config.RemoteURL + "/svg/" + baseName + "-light.svg"
if content, err := fetchRemoteFile(lightURL); err == nil {
iconContent = applySVGColor(content, colorCode)
servedFrom = "remote"
}
} else {
var standardURL string
if formatToServe == "svg" {
standardURL = config.RemoteURL + "/svg/" + iconName + ".svg"
standardURL = config.RemoteURL + "/svg/" + baseName + ".svg"
} else {
standardURL = config.RemoteURL + "/" + formatToServe + "/" + iconName + "." + formatToServe
standardURL = config.RemoteURL + "/" + formatToServe + "/" + baseName + "." + formatToServe
}
iconContent, _ = fetchRemoteFile(standardURL)
iconContent, err = fetchRemoteFile(standardURL)
if err != nil {
iconContent = ""
err = nil
if iconContent == "" && formatToServe != "webp" {
webpURL := config.RemoteURL + "/webp/" + baseName + ".webp"
if content, err := fetchRemoteFile(webpURL); 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 {
log.Printf("[ERROR] Icon not found: \"%s\"%s (source: %s)", iconName,
func() string { if colorCode != "" { return " with color " + colorCode } else { return "" } }(),
config.IconSource)
if iconContent == "" {
logf(logLevelError, "[ERROR] Icon not found: \"%s\"%s (source: %s) %v", baseName, colorSuffix, config.IconSource, formatDuration(time.Since(start)))
http.Error(w, "Icon not found", http.StatusNotFound)
return
}
cacheKey = getCacheKey(baseName+"."+formatToServe, colorCode)
cache.Set(cacheKey, iconContent)
log.Printf("[%s] Serving icon: \"%s\"%s (%s, source: %s)",
func() string { if primaryFallback { return "WARN" } else { return "SUCCESS" } }(),
iconName,
func() string {
if colorCode != "" { return " with color " + colorCode }
if primaryFallback { return " (PRIMARY_COLOR not set, using standard format)" }
return ""
}(),
formatToServe, config.IconSource)
level := "SUCCESS"
detail := colorSuffix
if primaryFallback {
level = "WARN"
detail = " (PRIMARY_COLOR not set, using default format)"
}
logf(logLevelInfo, "[%s] Serving icon: \"%s\"%s (%s, source: %s) %v", level, baseName, detail, formatToServe, servedFrom, formatDuration(time.Since(start)))
etag := computeETag(iconContent)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
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) {
start := time.Now()
filename := r.PathValue("filename")
if filename == "" {
@@ -360,91 +540,91 @@ func handleCustomIcon(w http.ResponseWriter, r *http.Request) {
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) {
if files, err := os.ReadDir("/app/icons/custom"); err == nil {
var fileList []string
for _, file := range files {
fileList = append(fileList, file.Name())
}
log.Printf("[DEBUG] Files in /app/icons/custom: %v", fileList)
customPath := filepath.Join(config.LocalPath, "custom", filename)
stat, err := os.Stat(customPath)
if err != nil {
if os.IsNotExist(err) {
logf(logLevelError, "[ERROR] Custom icon not found: \"%s\" %v", filename, formatDuration(time.Since(start)))
http.Error(w, "Custom icon not found", http.StatusNotFound)
} 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)
http.Error(w, "Custom icon not found", http.StatusNotFound)
return
}
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
}
data, err := os.ReadFile(customPath)
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)
return
}
ext := strings.ToLower(filepath.Ext(filename))
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"
}
cache.Set(cacheKey, string(data))
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.Write(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)
w.Header().Set("ETag", etag)
w.Header().Set("X-Cache", "MISS")
serveContent(w, r, contentType, string(data))
}
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()
validateConfig(config)
cache = NewCache(config.CacheTTL, config.CacheSize)
httpClient = &http.Client{Timeout: config.RemoteTimeout}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /custom/{filename}", handleCustomIcon)
// 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}", handleIcon)
mux.HandleFunc("GET /", handleRoot)
log.Printf("Icon server listening on port %s", config.Port)
log.Printf("Icon source: %s", func() string {
if config.IconSource == "local" {
switch config.IconSource {
case "local":
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("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!")
}